Przeglądaj źródła

Merge branch 'master' of https://github.com/PrivateBin/PrivateBin into fix-new-paste-default-formatter

rugk 1 miesiąc temu
rodzic
commit
70318fa5b3
100 zmienionych plików z 4522 dodań i 3524 usunięć
  1. 1 1
      .devcontainer/devcontainer.json
  2. 1 0
      .gitattributes
  3. 4 2
      .github/copilot-instructions.md
  4. 1 1
      .github/workflows/eslint.yml
  5. 1 1
      .github/workflows/test-results.yml
  6. 5 5
      .github/workflows/tests.yml
  7. 5 6
      .jshintrc
  8. 15 0
      .run/Frontend tests.run.xml
  9. 0 2
      .styleci.yml
  10. 29 1
      CHANGELOG.md
  11. 1 0
      CREDITS.md
  12. 2 2
      Makefile
  13. 1 1
      README.md
  14. 8 6
      SECURITY.md
  15. 8 4
      bin/administration
  16. 2 22
      bin/configuration-test-generator
  17. 19 5
      bin/migrate
  18. 14 11
      cfg/conf.sample.php
  19. 1 1
      composer.json
  20. 35 52
      composer.lock
  21. 1 1
      doc/Installation.md
  22. 5 3
      i18n/ar.json
  23. 11 9
      i18n/bg.json
  24. 5 3
      i18n/ca.json
  25. 6 4
      i18n/co.json
  26. 5 3
      i18n/cs.json
  27. 5 3
      i18n/de.json
  28. 5 3
      i18n/el.json
  29. 5 3
      i18n/en.json
  30. 5 3
      i18n/es.json
  31. 5 3
      i18n/et.json
  32. 5 3
      i18n/fa.json
  33. 5 3
      i18n/fi.json
  34. 5 3
      i18n/fr.json
  35. 5 3
      i18n/he.json
  36. 5 3
      i18n/hi.json
  37. 5 3
      i18n/hu.json
  38. 5 3
      i18n/id.json
  39. 5 3
      i18n/it.json
  40. 5 3
      i18n/ja.json
  41. 5 3
      i18n/jbo.json
  42. 200 198
      i18n/ko.json
  43. 5 3
      i18n/ku.json
  44. 5 3
      i18n/la.json
  45. 1 0
      i18n/languages.json
  46. 5 3
      i18n/lt.json
  47. 5 3
      i18n/nl.json
  48. 5 3
      i18n/no.json
  49. 5 3
      i18n/oc.json
  50. 5 3
      i18n/pl.json
  51. 5 3
      i18n/pt.json
  52. 5 3
      i18n/ro.json
  53. 5 3
      i18n/ru.json
  54. 5 3
      i18n/sk.json
  55. 8 6
      i18n/sl.json
  56. 5 3
      i18n/sv.json
  57. 45 43
      i18n/th.json
  58. 5 3
      i18n/tr.json
  59. 5 3
      i18n/uk.json
  60. 238 0
      i18n/zh-tw.json
  61. 5 3
      i18n/zh.json
  62. 0 2
      js/.istanbul.yml
  63. 0 5
      js/bootstrap-3.4.1.js
  64. 53 72
      js/common.js
  65. 2 2
      js/eslint.config.js
  66. 0 1
      js/jquery-3.7.1.js
  67. 22 0
      js/jsconfig.json
  68. 8 9
      js/legacy.js
  69. 362 275
      js/package-lock.json
  70. 5 2
      js/package.json
  71. 213 266
      js/privatebin.js
  72. 0 1
      js/purify-3.4.1.js
  73. 1 0
      js/purify-3.4.12.js
  74. 322 323
      js/test/Alert.js
  75. 261 142
      js/test/AttachmentViewer.js
  76. 68 63
      js/test/Check.js
  77. 142 114
      js/test/CopyToClipboard.js
  78. 46 34
      js/test/CryptTool.js
  79. 118 97
      js/test/DiscussionViewer.js
  80. 70 61
      js/test/Editor.js
  81. 316 268
      js/test/Helper.js
  82. 265 196
      js/test/I18n.js
  83. 234 197
      js/test/Model.js
  84. 227 163
      js/test/PasteStatus.js
  85. 147 104
      js/test/PasteViewer.js
  86. 78 32
      js/test/Prompt.js
  87. 23 15
      js/test/ServerInteraction.js
  88. 243 250
      js/test/TopNav.js
  89. 51 77
      js/test/UiHelper.js
  90. 165 47
      js/test/emailTemplateTest.js
  91. 47 45
      lib/Configuration.php
  92. 29 21
      lib/Controller.php
  93. 14 1
      lib/Data/AbstractData.php
  94. 29 30
      lib/Data/Database.php
  95. 54 24
      lib/Data/Filesystem.php
  96. 23 23
      lib/Data/GoogleCloudStorage.php
  97. 33 33
      lib/Data/S3Storage.php
  98. 0 38
      lib/Exception/JsonException.php
  99. 2 2
      lib/Filter.php
  100. 21 6
      lib/FormatV2.php

+ 1 - 1
.devcontainer/devcontainer.json

@@ -36,7 +36,7 @@
         }
         }
     },
     },
     "features": {
     "features": {
-        "ghcr.io/devcontainers-contrib/features/mocha:2": {},
+        "ghcr.io/devcontainers-extra/features/mocha:2": {},
         "ghcr.io/yassinedoghri/devcontainers/php-extensions-installer:1": {
         "ghcr.io/yassinedoghri/devcontainers/php-extensions-installer:1": {
             "extensions": "gd"
             "extensions": "gd"
         }
         }

+ 1 - 0
.gitattributes

@@ -9,6 +9,7 @@ js/.nycrc.yml export-ignore
 js/common.js export-ignore
 js/common.js export-ignore
 js/eslint.config.js export-ignore
 js/eslint.config.js export-ignore
 js/test/ export-ignore
 js/test/ export-ignore
+js/jsconfig.json export-ignore
 .codeclimate.yml export-ignore
 .codeclimate.yml export-ignore
 .csslintrc export-ignore
 .csslintrc export-ignore
 .devcontainer export-ignore
 .devcontainer export-ignore

+ 4 - 2
.github/copilot-instructions.md

@@ -37,8 +37,9 @@
 ### Build
 ### Build
 
 
 - **No explicit build step** for PHP. The web app is served directly from source.
 - **No explicit build step** for PHP. The web app is served directly from source.
-  - This means **composer directory** need to be comitted (_except_ of big optional dependences like Google Cloud like GCS support or similar!)
-- **For JavaScript:** There is no webpack/bundler step for release; browser JS is written in compatible ES6+ syntax, except in `legacy.js` (which must be designed to run cleanly even on ancient IE4 or Netscape to display the error message that a browser upgrade is necessary). We are trying to avoid jQuery in any new code and would like to eventually drop use of jQuery. We are considering modularizing the JS logic, but need to ensure to do so in a way that will work both in the browser as well as for node JS driven unit tests.
+  - This means **composer directory** need to be committed (_except_ of big optional dependences like Google Cloud like GCS support or similar!)
+  - But if you do add new PHP classes, you must run `make composer` to get the class added to the auto loader.
+- **For JavaScript:** There is no webpack/bundler step for release; browser JS is written in compatible ES6+ syntax, except in `legacy.js` (which must be designed to run cleanly even on ancient IE4 or Netscape to display the error message that a browser upgrade is necessary). We dropped use of jQuery. We are considering modularizing the JS logic, but need to ensure to do so in a way that will work both in the browser as well as for node JS driven unit tests.
 
 
 ### Run
 ### Run
 
 
@@ -117,3 +118,4 @@
 - **Always run `composer install` before PHP tests, and `npm install` before JS tests.**
 - **Always run `composer install` before PHP tests, and `npm install` before JS tests.**
 - **Validate all changes by running both PHP and JS tests.**
 - **Validate all changes by running both PHP and JS tests.**
 - **Review `.github/workflows/` for the latest validation pipeline steps.**
 - **Review `.github/workflows/` for the latest validation pipeline steps.**
+- Do update the CHANGELOG.md with an ADDED, CHANGED or FIXED list item to the not yet released version on top. New lines get added below the last line that matches the type of your change, but don't mix the types. See earlier releases for guidance on order and wording. If your change addresses an issue, do include the issue ID in brackets at the end. The lines should be short, details are explained in the pull request.

+ 1 - 1
.github/workflows/eslint.yml

@@ -24,7 +24,7 @@ jobs:
           persist-credentials: false
           persist-credentials: false
 
 
       - name: Set up Node.js
       - name: Set up Node.js
-        uses: actions/setup-node@v6
+        uses: actions/setup-node@v7
         with:
         with:
           node-version: '20'
           node-version: '20'
 
 

+ 1 - 1
.github/workflows/test-results.yml

@@ -25,7 +25,7 @@ jobs:
 
 
     steps:
     steps:
        - name: Download and Extract Artifacts
        - name: Download and Extract Artifacts
-         uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151
+         uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7
          with:
          with:
           run_id: ${{ github.event.workflow_run.id }}
           run_id: ${{ github.event.workflow_run.id }}
           path: artifacts
           path: artifacts

+ 5 - 5
.github/workflows/tests.yml

@@ -57,7 +57,7 @@ jobs:
         key: ${{ runner.os }}-${{ env.extensions-cache-key }}
         key: ${{ runner.os }}-${{ env.extensions-cache-key }}
 
 
     - name: Cache extensions
     - name: Cache extensions
-      uses: actions/cache@v5
+      uses: actions/cache@v6
       with:
       with:
         path: ${{ steps.extcache.outputs.dir }}
         path: ${{ steps.extcache.outputs.dir }}
         key: ${{ steps.extcache.outputs.key }}
         key: ${{ steps.extcache.outputs.key }}
@@ -93,7 +93,7 @@ jobs:
       shell: bash
       shell: bash
 
 
     - name: Cache dependencies
     - name: Cache dependencies
-      uses: actions/cache@v5
+      uses: actions/cache@v6
       with:
       with:
         path: "${{ steps.composer-cache.outputs.dir }}"
         path: "${{ steps.composer-cache.outputs.dir }}"
         key: "${{ runner.os }}-composer-${{ steps.get-date.outputs.date }}-${{ hashFiles('**/composer.json') }}"
         key: "${{ runner.os }}-composer-${{ steps.get-date.outputs.date }}-${{ hashFiles('**/composer.json') }}"
@@ -147,7 +147,7 @@ jobs:
         key: ${{ runner.os }}-${{ env.extensions-cache-key }}
         key: ${{ runner.os }}-${{ env.extensions-cache-key }}
 
 
     - name: Cache extensions
     - name: Cache extensions
-      uses: actions/cache@v5
+      uses: actions/cache@v6
       with:
       with:
         path: ${{ steps.extcache.outputs.dir }}
         path: ${{ steps.extcache.outputs.dir }}
         key: ${{ steps.extcache.outputs.key }}
         key: ${{ steps.extcache.outputs.key }}
@@ -183,7 +183,7 @@ jobs:
       shell: bash
       shell: bash
 
 
     - name: Cache dependencies
     - name: Cache dependencies
-      uses: actions/cache@v5
+      uses: actions/cache@v6
       with:
       with:
         path: "${{ steps.composer-cache.outputs.dir }}"
         path: "${{ steps.composer-cache.outputs.dir }}"
         key: "${{ runner.os }}-composer-${{ steps.get-date.outputs.date }}-${{ hashFiles('**/composer.json') }}"
         key: "${{ runner.os }}-composer-${{ steps.get-date.outputs.date }}-${{ hashFiles('**/composer.json') }}"
@@ -224,7 +224,7 @@ jobs:
         persist-credentials: false
         persist-credentials: false
 
 
     - name: Setup Node
     - name: Setup Node
-      uses: actions/setup-node@v6
+      uses: actions/setup-node@v7
       with:
       with:
         node-version: '20'
         node-version: '20'
         cache: 'npm'
         cache: 'npm'

+ 5 - 6
.jshintrc

@@ -2,7 +2,7 @@
     "bitwise": true,
     "bitwise": true,
     "curly": true,
     "curly": true,
     "eqeqeq": true,
     "eqeqeq": true,
-    "esversion": 6,
+    "esversion": 2018,
     "forin": true,
     "forin": true,
     "freeze": true,
     "freeze": true,
     "futurehostile": true,
     "futurehostile": true,
@@ -17,11 +17,12 @@
     "quotmark": "single",
     "quotmark": "single",
     "singleGroups": true,
     "singleGroups": true,
     "strict": true,
     "strict": true,
+    "globalstrict": true,
     "undef": true,
     "undef": true,
     "unused": true,
     "unused": true,
-    "jquery": true,
+    "jquery": false,
     "browser": true,
     "browser": true,
-    "predef": {
+    "globals": {
       "after": true,
       "after": true,
       "before": true,
       "before": true,
       "cleanup": true,
       "cleanup": true,
@@ -36,9 +37,7 @@
       "jsdom": true,
       "jsdom": true,
       "require": false,
       "require": false,
       "setTimeout": false,
       "setTimeout": false,
-      "window": true
-    },
-    "globals": {
+      "window": true,
       "DOMPurify": true,
       "DOMPurify": true,
       "kjua": true
       "kjua": true
     }
     }

+ 15 - 0
.run/Frontend tests.run.xml

@@ -0,0 +1,15 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="Frontend tests" type="mocha-javascript-test-runner">
+    <node-interpreter>project</node-interpreter>
+    <node-options />
+    <mocha-package>$PROJECT_DIR$/js/node_modules/mocha</mocha-package>
+    <working-directory>$PROJECT_DIR$/js</working-directory>
+    <pass-parent-env>true</pass-parent-env>
+    <ui>bdd</ui>
+    <extra-mocha-options />
+    <test-kind>DIRECTORY</test-kind>
+    <test-directory>$PROJECT_DIR$/js/test</test-directory>
+    <recursive>true</recursive>
+    <method v="2" />
+  </configuration>
+</component>

+ 0 - 2
.styleci.yml

@@ -5,7 +5,6 @@ risky: false
 enabled:
 enabled:
   - align_equals
   - align_equals
   - concat_with_spaces
   - concat_with_spaces
-  - long_array_syntax
   - no_empty_comment
   - no_empty_comment
   - pre_increment
   - pre_increment
 
 
@@ -26,7 +25,6 @@ disabled:
   - phpdoc_single_line_var_spacing
   - phpdoc_single_line_var_spacing
   - phpdoc_summary
   - phpdoc_summary
   - post_increment
   - post_increment
-  - short_array_syntax
   - single_line_after_imports
   - single_line_after_imports
   - unalign_equals
   - unalign_equals
 
 

+ 29 - 1
CHANGELOG.md

@@ -1,9 +1,37 @@
 # PrivateBin version history
 # PrivateBin version history
 
 
-## 2.0.5 (not yet released)
+## 2.1.0 (not yet released)
+* ADDED: Added `shortenviachhoto` endpoint with an `chhoto` configuration section
+* ADDED: Translation for Chinese (Traditional)
+* ADDED: Subject line to the "Email" share button's mailto link (#928)
+* CHANGED: We removed jQuery in the Frontend and replaced it with vanilla JS.
+* CHANGED: Removed the unmaintained js-verify and replaced it with fast-check library.
+* CHANGED: Added a `jsconfig.json` in order to check the types of JavaScript.
+* CHANGED: Removed support for Bootstrap 3 as it requires jQuery.
+* CHANGED: Encode documents with `TextEncoder` in `compress()`, instead of the `utf16To8()` percent-encoding round trip, which allocated ~20 times the payload
+* CHANGED: Upgrading libraries to: ip-lib 1.22.1
+* FIXED: Decode decompressed documents with `TextDecoder` instead of `utf8To16()`, which allocated ~3.4 GB for a 100 MB document and killed the tab (#2027)
+* FIXED: Prevent browsers guessing MIME types on JSON(LD) API responses (#164)
+* FIXED: Insert quoted base path into JSON-LD documents
+* FIXED: Several potential issues in handling comment files (#1901)
+
+## 2.0.6 (2026-08-08)
+* CHANGED: Stricter MIME type validation, divergent files get no preview and forced download link
+* CHANGED: Upgrading libraries to: DOMpurify 3.4.12
+* CHANGED: Switch to PHP native JsonException type
+* FIXED: Restrict MIME types accepted for PDF & sanitized SVG previews to prevent HTML render fallback, incl. DOMpurify bypass using multi-byte encoded HTML entities
+* FIXED: Gracefully handle YOURLS replies with a 200 status code but no shorturl, instead of raising a TypeError
+* FIXED: Return "Invalid data." instead of HTTP 500 on malformed v2 JSON payloads (#1883)
+* FIXED: Dead PATH validation guard in Controller, the check for a missing trailing directory separator never ran (#1887)
+* FIXED: Only treat an exact deletetoken query parameter as a deletion response
+
+## 2.0.5 (2026-07-11)
+* CHANGED: Show OS-specific copy hotkey hint (Cmd+c on Mac, Ctrl+c on others) (#1506)
+* FIXED: Prevent browsers from rendering unsafe attachments like HTML in a new tab
 * FIXED: State corruption after "Remove attachment" (#1824)
 * FIXED: State corruption after "Remove attachment" (#1824)
 * FIXED: Copy button is hidden if the document is made as markdown (#1703)
 * FIXED: Copy button is hidden if the document is made as markdown (#1703)
 * FIXED: Shortened URLs from YOURLS received but failed to parse (#1844)
 * FIXED: Shortened URLs from YOURLS received but failed to parse (#1844)
+* FIXED: Insert only base path in JSON API responses, without GET parameters
 
 
 ## 2.0.4 (2026-05-03)
 ## 2.0.4 (2026-05-03)
 * ADDED: Translations for Swedish & Persian
 * ADDED: Translations for Swedish & Persian

+ 1 - 0
CREDITS.md

@@ -72,3 +72,4 @@
 * Edward205 - Romanian
 * Edward205 - Romanian
 * babiloof - Swedish
 * babiloof - Swedish
 * Ali Fani - Persian
 * Ali Fani - Persian
+* Peter Dave Hello - Chinese (Traditional)

+ 2 - 2
Makefile

@@ -1,7 +1,7 @@
 .PHONY: all coverage coverage-js coverage-php doc doc-js doc-php increment sign test test-js test-php help
 .PHONY: all coverage coverage-js coverage-php doc doc-js doc-php increment sign test test-js test-php help
 
 
-CURRENT_VERSION = 2.0.4
-VERSION ?= 2.0.5
+CURRENT_VERSION = 2.0.6
+VERSION ?= 2.0.6
 VERSION_FILES = README.md SECURITY.md doc/Installation.md js/package.json lib/Controller.php Makefile
 VERSION_FILES = README.md SECURITY.md doc/Installation.md js/package.json lib/Controller.php Makefile
 REGEX_CURRENT_VERSION := $(shell echo $(CURRENT_VERSION) | sed "s/\./\\\./g")
 REGEX_CURRENT_VERSION := $(shell echo $(CURRENT_VERSION) | sed "s/\./\\\./g")
 REGEX_VERSION := $(shell echo $(VERSION) | sed "s/\./\\\./g")
 REGEX_VERSION := $(shell echo $(VERSION) | sed "s/\./\\\./g")

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # [![PrivateBin](https://raw.githubusercontent.com/PrivateBin/assets/master/images/preview/logoSmall.png)](https://privatebin.info/)
 # [![PrivateBin](https://raw.githubusercontent.com/PrivateBin/assets/master/images/preview/logoSmall.png)](https://privatebin.info/)
 
 
-*Current version: 2.0.4*
+*Current version: 2.0.6*
 
 
 **PrivateBin** is a minimalist, open source online
 **PrivateBin** is a minimalist, open source online
 [pastebin](https://en.wikipedia.org/wiki/Pastebin)
 [pastebin](https://en.wikipedia.org/wiki/Pastebin)

+ 8 - 6
SECURITY.md

@@ -4,18 +4,20 @@
 
 
 | Version | Supported          |
 | Version | Supported          |
 | ------- | ------------------ |
 | ------- | ------------------ |
-| 2.0.4   | :heavy_check_mark: |
-| < 2.0.4 | :x:                |
+| 2.0.6   | :heavy_check_mark: |
+| < 2.0.6 | :x:                |
 
 
 ## Reporting a Vulnerability
 ## Reporting a Vulnerability
 
 
-You can send us email at security@privatebin.org. You should be able to get
+We suggest to [use the corresponding GitHub form](https://github.com/PrivateBin/PrivateBin/security/advisories/new)
+to report a new vulnerability directly on GitHub. [It can be handled there](https://docs.github.com/code-security/how-tos/report-and-fix-vulnerabilities/report-privately)
+and all necessary steps like verifying the vulnerability, crediting the finder
+and drafting a security advisory will be done there.
+
+You can also send us email at security@privatebin.org. You should be able to get
 a response within a week (usually during the next weekend). The respondee will
 a response within a week (usually during the next weekend). The respondee will
 reply from their personal address and can offer you their GPG public key to
 reply from their personal address and can offer you their GPG public key to
 support end-to-end encrypted communication on sensitive topics or attachments.
 support end-to-end encrypted communication on sensitive topics or attachments.
 
 
-You can also [use the corresponding GitHub form](https://github.com/PrivateBin/PrivateBin/security/advisories/new)
-to report a new vulnerability directly on GitHub.
-
 You can also contact us via the regular issue tracker if the risk of early
 You can also contact us via the regular issue tracker if the risk of early
 publication is low or you would request input from other PrivateBin users.
 publication is low or you would request input from other PrivateBin users.

+ 8 - 4
bin/administration

@@ -121,7 +121,7 @@ class Administration
             self::_error('instance not using Filesystem storage, no directories to empty', 4);
             self::_error('instance not using Filesystem storage, no directories to empty', 4);
         }
         }
         $dir = $this->_conf->getKey('dir', 'model_options');
         $dir = $this->_conf->getKey('dir', 'model_options');
-        passthru("find $dir -type d -empty -delete", $code);
+        passthru('find ' . escapeshellarg($dir) . ' -type d -empty -delete', $code);
         exit($code);
         exit($code);
     }
     }
 
 
@@ -205,7 +205,7 @@ EOT, PHP_EOL;
     private function _option($short, $long)
     private function _option($short, $long)
     {
     {
         foreach (array($short, $long) as $key) {
         foreach (array($short, $long) as $key) {
-            if (array_key_exists($key, $this->_opts)) {
+            if ($key !== null && array_key_exists($key, $this->_opts)) {
                 return $this->_opts[$key];
                 return $this->_opts[$key];
             }
             }
         }
         }
@@ -233,9 +233,13 @@ EOT, PHP_EOL;
             self::_help(2);
             self::_help(2);
         }
         }
 
 
-        $this->_opts = getopt('hd:elps', array('help', 'delete:', 'delete-all', 'delete-v1', 'empty-dirs', 'list-ids', 'purge', 'statistics'));
+        $this->_opts = getopt(
+            'hd:elps',
+            array('help', 'delete:', 'delete-all', 'delete-v1', 'empty-dirs', 'list-ids', 'purge', 'statistics'),
+            $restIndex
+        );
 
 
-        if (!$this->_opts) {
+        if (!$this->_opts || count($this->_opts) !== 1 || $restIndex < $arguments) {
             self::_error_echo('unsupported arguments given');
             self::_error_echo('unsupported arguments given');
             echo PHP_EOL;
             echo PHP_EOL;
             self::_help(3);
             self::_help(3);

+ 2 - 22
bin/configuration-test-generator

@@ -183,27 +183,7 @@ new ConfigurationTestGenerator(array(
                 ),
                 ),
             ),
             ),
             'affects' => $vd,
             'affects' => $vd,
-        ), array(
-            'setting' => 'bootstrap',
-            'tests'   => array(
-                array(
-                    'type' => 'DoesNotMatchRegularExpression',
-                    'args' => array(
-                        '#<link[^>]+type="text/css"[^>]+rel="stylesheet"[^>]+href="css/bootstrap5/privatebin\.css\\?\d[\d\.]+\d+"[^>]*/>#',
-                        '$content',
-                        'removes "bootstrap5" stylesheet correctly',
-                    ),
-                ), array(
-                    'type' => 'MatchesRegularExpression',
-                    'args' => array(
-                        '#<link[^>]+type="text/css"[^>]+rel="stylesheet"[^>]+href="css/bootstrap/bootstrap-\d[\d\.]+\d\.css"[^>]*/>#',
-                        '$content',
-                        'outputs "bootstrap" stylesheet correctly',
-                    ),
-                ),
-            ),
-            'affects' => $vd,
-        ),
+        )
     ),
     ),
     'main/sizelimit' => array(
     'main/sizelimit' => array(
         array(
         array(
@@ -387,7 +367,7 @@ class ConfigurationTestGenerator
                         }
                         }
                         $args = array();
                         $args = array();
                         foreach ($test['args'] as $arg) {
                         foreach ($test['args'] as $arg) {
-                            if (is_string($arg) && strpos($arg, '$') === 0) {
+                            if (is_string($arg) && str_starts_with($arg, '$')) {
                                 $args[] = $arg;
                                 $args[] = $arg;
                             } else {
                             } else {
                                 $args[] = Helper::varExportMin($arg, true);
                                 $args[] = Helper::varExportMin($arg, true);

+ 19 - 5
bin/migrate

@@ -67,7 +67,10 @@ $ids      = $srcstore->getAllPastes();
 
 
 foreach ($ids as $id) {
 foreach ($ids as $id) {
     debug("Reading document ID " . $id);
     debug("Reading document ID " . $id);
-    $paste    = $srcstore->read($id);
+    $paste = $srcstore->read($id);
+    if (!is_array($paste)) {
+        dieerr("Unable to read document ID " . $id);
+    }
     $comments = $srcstore->readComments($id);
     $comments = $srcstore->readComments($id);
 
 
     savePaste($force_overwrite, $dryrun, $id, $paste, $dststore);
     savePaste($force_overwrite, $dryrun, $id, $paste, $dststore);
@@ -107,7 +110,10 @@ function saveComment ($force_overwrite, $dryrun, $pasteid, $comment, $dststore)
         if (!$dryrun) {
         if (!$dryrun) {
             debug("Saving document ID " . $pasteid . ", parent id " .
             debug("Saving document ID " . $pasteid . ", parent id " .
                   $parentid . ", comment id " . $commentid);
                   $parentid . ", comment id " . $commentid);
-            $dststore->createComment($pasteid, $parentid, $commentid, $comment);
+            if (!$dststore->createComment($pasteid, $parentid, $commentid, $comment)) {
+                dieerr("Unable to save document ID " . $pasteid . ", parent id " .
+                       $parentid . ", comment id " . $commentid);
+            }
         } else {
         } else {
             debug("Would save document ID " . $pasteid . ", parent id " .
             debug("Would save document ID " . $pasteid . ", parent id " .
                   $parentid . ", comment id " . $commentid);
                   $parentid . ", comment id " . $commentid);
@@ -116,7 +122,10 @@ function saveComment ($force_overwrite, $dryrun, $pasteid, $comment, $dststore)
         if (!$dryrun) {
         if (!$dryrun) {
             debug("Overwriting document ID " . $pasteid . ", parent id " .
             debug("Overwriting document ID " . $pasteid . ", parent id " .
                   $parentid . ", comment id " . $commentid);
                   $parentid . ", comment id " . $commentid);
-            $dststore->createComment($pasteid, $parentid, $commentid, $comment);
+            if (!$dststore->createComment($pasteid, $parentid, $commentid, $comment)) {
+                dieerr("Unable to overwrite document ID " . $pasteid . ", parent id " .
+                       $parentid . ", comment id " . $commentid);
+            }
         } else {
         } else {
             debug("Would overwrite document ID " . $pasteid . ", parent id " .
             debug("Would overwrite document ID " . $pasteid . ", parent id " .
                   $parentid . ", comment id " . $commentid);
                   $parentid . ", comment id " . $commentid);
@@ -137,14 +146,19 @@ function savePaste ($force_overwrite, $dryrun, $pasteid, $paste, $dststore)
     if (!$dststore->exists($pasteid)) {
     if (!$dststore->exists($pasteid)) {
         if (!$dryrun) {
         if (!$dryrun) {
             debug("Saving document ID " . $pasteid);
             debug("Saving document ID " . $pasteid);
-            $dststore->create($pasteid, $paste);
+            if (!$dststore->create($pasteid, $paste)) {
+                dieerr("Unable to save document ID " . $pasteid);
+            }
         } else {
         } else {
             debug("Would save document ID " . $pasteid);
             debug("Would save document ID " . $pasteid);
         }
         }
     } else if ($force_overwrite) {
     } else if ($force_overwrite) {
         if (!$dryrun) {
         if (!$dryrun) {
             debug("Overwriting document ID " . $pasteid);
             debug("Overwriting document ID " . $pasteid);
-            $dststore->create($pasteid, $paste);
+            $dststore->delete($pasteid);
+            if (!$dststore->create($pasteid, $paste)) {
+                dieerr("Unable to overwrite document ID " . $pasteid);
+            }
         } else {
         } else {
             debug("Would overwrite document ID " . $pasteid);
             debug("Would overwrite document ID " . $pasteid);
         }
         }

+ 14 - 11
cfg/conf.sample.php

@@ -49,17 +49,9 @@ templateselection = false
 
 
 ; List of available for selection templates when "templateselection" option is enabled
 ; List of available for selection templates when "templateselection" option is enabled
 availabletemplates[] = "bootstrap5"
 availabletemplates[] = "bootstrap5"
-availabletemplates[] = "bootstrap"
-availabletemplates[] = "bootstrap-page"
-availabletemplates[] = "bootstrap-dark"
-availabletemplates[] = "bootstrap-dark-page"
-availabletemplates[] = "bootstrap-compact"
-availabletemplates[] = "bootstrap-compact-page"
-
-; set the template your installs defaults to, defaults to "bootstrap5" (tpl/bootstrap5.php), also
-; bootstrap template (tpl/bootstrap.php) and it's variants: "bootstrap-dark", "bootstrap-compact", "bootstrap-page",
-; which can be combined with "-dark" and "-compact" for "bootstrap-dark-page",
-; "bootstrap-compact-page" - previews at:
+
+; set the template your installs defaults to, defaults to "bootstrap5" (tpl/bootstrap5.php)
+; previews at:
 ; https://privatebin.info/screenshots.html
 ; https://privatebin.info/screenshots.html
 ; template = "bootstrap5"
 ; template = "bootstrap5"
 
 
@@ -274,6 +266,17 @@ dir = PATH "data"
 ;version = "latest"
 ;version = "latest"
 ;bucket = "my-bucket"
 ;bucket = "my-bucket"
 
 
+;[chhoto]
+; - Chhoto requires you to make a post call with a generated API key.
+;   use this section to setup the API key and URL. In order to use this section,
+;   "urlshortener" needs to point to the base URL of your PrivateBin
+;   instance with "?shortenviachhoto&link=" appended. For example:
+;   urlshortener = "${basepath}?shortenviachhoto&link="
+;    This URL will in turn call Chhoto on the server side, using the URL from
+;   "apiurl" and the API Key from the "apikey" parameters below.
+; apiurl = "https://chhoto-url.example.com/api/new"
+; apikey = "your_api_key"
+
 ;[shlink]
 ;[shlink]
 ; - Shlink requires you to make a post call with a generated API key.
 ; - Shlink requires you to make a post call with a generated API key.
 ;   use this section to setup the API key and URL. In order to use this section,
 ;   use this section to setup the API key and URL. In order to use this section,

+ 1 - 1
composer.json

@@ -26,7 +26,7 @@
 	"require" : {
 	"require" : {
 		"php": "^7.4 || ^8.0",
 		"php": "^7.4 || ^8.0",
 		"jdenticon/jdenticon": "2.0.0",
 		"jdenticon/jdenticon": "2.0.0",
-		"mlocati/ip-lib": "1.22.0",
+		"mlocati/ip-lib": "1.22.1",
 		"symfony/polyfill-php80": "1.34.0",
 		"symfony/polyfill-php80": "1.34.0",
 		"yzalis/identicon": "2.0.0"
 		"yzalis/identicon": "2.0.0"
 	},
 	},

+ 35 - 52
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
         "This file is @generated automatically"
     ],
     ],
-    "content-hash": "93df7ad74baea1e462b30852a480707d",
+    "content-hash": "527cd12822b23540702e67b958cbfdcf",
     "packages": [
     "packages": [
         {
         {
             "name": "jdenticon/jdenticon",
             "name": "jdenticon/jdenticon",
@@ -57,16 +57,16 @@
         },
         },
         {
         {
             "name": "mlocati/ip-lib",
             "name": "mlocati/ip-lib",
-            "version": "1.22.0",
+            "version": "1.22.1",
             "source": {
             "source": {
                 "type": "git",
                 "type": "git",
                 "url": "https://github.com/mlocati/ip-lib.git",
                 "url": "https://github.com/mlocati/ip-lib.git",
-                "reference": "4e40ffd3bf9989db19403d89c4d8be44b87b8a91"
+                "reference": "f096c0e9ec8bd4691ee45394cfb77c97c6bba272"
             },
             },
             "dist": {
             "dist": {
                 "type": "zip",
                 "type": "zip",
-                "url": "https://api.github.com/repos/mlocati/ip-lib/zipball/4e40ffd3bf9989db19403d89c4d8be44b87b8a91",
-                "reference": "4e40ffd3bf9989db19403d89c4d8be44b87b8a91",
+                "url": "https://api.github.com/repos/mlocati/ip-lib/zipball/f096c0e9ec8bd4691ee45394cfb77c97c6bba272",
+                "reference": "f096c0e9ec8bd4691ee45394cfb77c97c6bba272",
                 "shasum": ""
                 "shasum": ""
             },
             },
             "require": {
             "require": {
@@ -112,7 +112,7 @@
             ],
             ],
             "support": {
             "support": {
                 "issues": "https://github.com/mlocati/ip-lib/issues",
                 "issues": "https://github.com/mlocati/ip-lib/issues",
-                "source": "https://github.com/mlocati/ip-lib/tree/1.22.0"
+                "source": "https://github.com/mlocati/ip-lib/tree/1.22.1"
             },
             },
             "funding": [
             "funding": [
                 {
                 {
@@ -124,7 +124,7 @@
                     "type": "other"
                     "type": "other"
                 }
                 }
             ],
             ],
-            "time": "2025-10-15T12:35:09+00:00"
+            "time": "2026-09-17T09:24:44+00:00"
         },
         },
         {
         {
             "name": "symfony/polyfill-php80",
             "name": "symfony/polyfill-php80",
@@ -396,20 +396,19 @@
         },
         },
         {
         {
             "name": "nikic/php-parser",
             "name": "nikic/php-parser",
-            "version": "v5.7.0",
+            "version": "v5.9.0",
             "source": {
             "source": {
                 "type": "git",
                 "type": "git",
                 "url": "https://github.com/nikic/PHP-Parser.git",
                 "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+                "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def"
             },
             },
             "dist": {
             "dist": {
                 "type": "zip",
                 "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
-                "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def",
+                "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def",
                 "shasum": ""
                 "shasum": ""
             },
             },
             "require": {
             "require": {
-                "ext-ctype": "*",
                 "ext-json": "*",
                 "ext-json": "*",
                 "ext-tokenizer": "*",
                 "ext-tokenizer": "*",
                 "php": ">=7.4"
                 "php": ">=7.4"
@@ -448,9 +447,9 @@
             ],
             ],
             "support": {
             "support": {
                 "issues": "https://github.com/nikic/PHP-Parser/issues",
                 "issues": "https://github.com/nikic/PHP-Parser/issues",
-                "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+                "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0"
             },
             },
-            "time": "2025-12-06T11:56:16+00:00"
+            "time": "2026-09-13T18:51:52+00:00"
         },
         },
         {
         {
             "name": "phar-io/manifest",
             "name": "phar-io/manifest",
@@ -891,25 +890,25 @@
         },
         },
         {
         {
             "name": "phpunit/phpunit",
             "name": "phpunit/phpunit",
-            "version": "9.6.34",
+            "version": "9.6.36",
             "source": {
             "source": {
                 "type": "git",
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "b36f02317466907a230d3aa1d34467041271ef4a"
+                "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9"
             },
             },
             "dist": {
             "dist": {
                 "type": "zip",
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a",
-                "reference": "b36f02317466907a230d3aa1d34467041271ef4a",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/abab27ed286d3e1246fbbfe6b56bfd732d945ec9",
+                "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9",
                 "shasum": ""
                 "shasum": ""
             },
             },
             "require": {
             "require": {
                 "doctrine/instantiator": "^1.5.0 || ^2",
                 "doctrine/instantiator": "^1.5.0 || ^2",
                 "ext-dom": "*",
                 "ext-dom": "*",
+                "ext-filter": "*",
                 "ext-json": "*",
                 "ext-json": "*",
                 "ext-libxml": "*",
                 "ext-libxml": "*",
                 "ext-mbstring": "*",
                 "ext-mbstring": "*",
-                "ext-xml": "*",
                 "ext-xmlwriter": "*",
                 "ext-xmlwriter": "*",
                 "myclabs/deep-copy": "^1.13.4",
                 "myclabs/deep-copy": "^1.13.4",
                 "phar-io/manifest": "^2.0.4",
                 "phar-io/manifest": "^2.0.4",
@@ -925,7 +924,7 @@
                 "sebastian/comparator": "^4.0.10",
                 "sebastian/comparator": "^4.0.10",
                 "sebastian/diff": "^4.0.6",
                 "sebastian/diff": "^4.0.6",
                 "sebastian/environment": "^5.1.5",
                 "sebastian/environment": "^5.1.5",
-                "sebastian/exporter": "^4.0.8",
+                "sebastian/exporter": "^4.0.9",
                 "sebastian/global-state": "^5.0.8",
                 "sebastian/global-state": "^5.0.8",
                 "sebastian/object-enumerator": "^4.0.4",
                 "sebastian/object-enumerator": "^4.0.4",
                 "sebastian/resource-operations": "^3.0.4",
                 "sebastian/resource-operations": "^3.0.4",
@@ -974,31 +973,15 @@
             "support": {
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.36"
             },
             },
             "funding": [
             "funding": [
                 {
                 {
-                    "url": "https://phpunit.de/sponsors.html",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/sebastianbergmann",
-                    "type": "github"
-                },
-                {
-                    "url": "https://liberapay.com/sebastianbergmann",
-                    "type": "liberapay"
-                },
-                {
-                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
-                    "type": "thanks_dev"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
-                    "type": "tidelift"
+                    "url": "https://phpunit.de/sponsoring.html",
+                    "type": "other"
                 }
                 }
             ],
             ],
-            "time": "2026-01-27T05:45:00+00:00"
+            "time": "2026-08-11T06:25:15+00:00"
         },
         },
         {
         {
             "name": "sebastian/cli-parser",
             "name": "sebastian/cli-parser",
@@ -1441,16 +1424,16 @@
         },
         },
         {
         {
             "name": "sebastian/exporter",
             "name": "sebastian/exporter",
-            "version": "4.0.8",
+            "version": "4.0.9",
             "source": {
             "source": {
                 "type": "git",
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/exporter.git",
                 "url": "https://github.com/sebastianbergmann/exporter.git",
-                "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
+                "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7"
             },
             },
             "dist": {
             "dist": {
                 "type": "zip",
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c",
-                "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/4352c1a3df741a7ba9e61af6fed51d1fee41cbf7",
+                "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7",
                 "shasum": ""
                 "shasum": ""
             },
             },
             "require": {
             "require": {
@@ -1506,7 +1489,7 @@
             ],
             ],
             "support": {
             "support": {
                 "issues": "https://github.com/sebastianbergmann/exporter/issues",
                 "issues": "https://github.com/sebastianbergmann/exporter/issues",
-                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8"
+                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.9"
             },
             },
             "funding": [
             "funding": [
                 {
                 {
@@ -1526,7 +1509,7 @@
                     "type": "tidelift"
                     "type": "tidelift"
                 }
                 }
             ],
             ],
-            "time": "2025-09-24T06:03:27+00:00"
+            "time": "2026-08-11T04:55:59+00:00"
         },
         },
         {
         {
             "name": "sebastian/global-state",
             "name": "sebastian/global-state",
@@ -1775,16 +1758,16 @@
         },
         },
         {
         {
             "name": "sebastian/recursion-context",
             "name": "sebastian/recursion-context",
-            "version": "4.0.6",
+            "version": "4.0.7",
             "source": {
             "source": {
                 "type": "git",
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/recursion-context.git",
                 "url": "https://github.com/sebastianbergmann/recursion-context.git",
-                "reference": "539c6691e0623af6dc6f9c20384c120f963465a0"
+                "reference": "c85be6922b7fd365942b986b9a50397d65407611"
             },
             },
             "dist": {
             "dist": {
                 "type": "zip",
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0",
-                "reference": "539c6691e0623af6dc6f9c20384c120f963465a0",
+                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c85be6922b7fd365942b986b9a50397d65407611",
+                "reference": "c85be6922b7fd365942b986b9a50397d65407611",
                 "shasum": ""
                 "shasum": ""
             },
             },
             "require": {
             "require": {
@@ -1826,7 +1809,7 @@
             "homepage": "https://github.com/sebastianbergmann/recursion-context",
             "homepage": "https://github.com/sebastianbergmann/recursion-context",
             "support": {
             "support": {
                 "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
                 "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
-                "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6"
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.7"
             },
             },
             "funding": [
             "funding": [
                 {
                 {
@@ -1846,7 +1829,7 @@
                     "type": "tidelift"
                     "type": "tidelift"
                 }
                 }
             ],
             ],
-            "time": "2025-08-10T06:57:39+00:00"
+            "time": "2026-08-11T05:25:24+00:00"
         },
         },
         {
         {
             "name": "sebastian/resource-operations",
             "name": "sebastian/resource-operations",

+ 1 - 1
doc/Installation.md

@@ -206,7 +206,7 @@ CREATE INDEX parent ON prefix_comment(pasteid);
 CREATE TABLE prefix_config (
 CREATE TABLE prefix_config (
     id CHAR(16) NOT NULL, value TEXT, PRIMARY KEY (id)
     id CHAR(16) NOT NULL, value TEXT, PRIMARY KEY (id)
 );
 );
-INSERT INTO prefix_config VALUES('VERSION', '2.0.4');
+INSERT INTO prefix_config VALUES('VERSION', '2.0.6');
 ```
 ```
 
 
 In **PostgreSQL**, the `data`, `attachment`, `nickname` and `vizhash` columns
 In **PostgreSQL**, the `data`, `attachment`, `nickname` and `vizhash` columns

+ 5 - 3
i18n/ar.json

@@ -151,7 +151,7 @@
     "server error or not responding": "خطأ في الخادم أو لا يستجيب",
     "server error or not responding": "خطأ في الخادم أو لا يستجيب",
     "Could not post comment: %s": "لا يمكن نشر تعليق: %s",
     "Could not post comment: %s": "لا يمكن نشر تعليق: %s",
     "Sending document…": "يُرسل لصق…",
     "Sending document…": "يُرسل لصق…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "لصقك هو <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(اضغط على <kbd>Ctrl</kbd> + <kbd>c</kbd> للنسخ)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "لصقك هو <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(اضغط على <kbd>%s</kbd> + <kbd>c</kbd> للنسخ)</span>",
     "Delete data": "حذف البيانات",
     "Delete data": "حذف البيانات",
     "Could not create document: %s": "تعذر إنشاء اللصق: %s",
     "Could not create document: %s": "تعذر إنشاء اللصق: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "لا يمكن فك تشفير اللصق: مفتاح فك التشفير مفقود في URL (هل استخدمت معيد توجيه أو أداة تقصير لعناوين URL تزيل جزءًا من عنوان URL؟)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "لا يمكن فك تشفير اللصق: مفتاح فك التشفير مفقود في URL (هل استخدمت معيد توجيه أو أداة تقصير لعناوين URL تزيل جزءًا من عنوان URL؟)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "خطأ في فك ضغط اللصق، متصفحك لا يدعم WebAssembly. الرجاء استخدام متصفح آخر لعرض هذه اللصقة.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "خطأ في فك ضغط اللصق، متصفحك لا يدعم WebAssembly. الرجاء استخدام متصفح آخر لعرض هذه اللصقة.",
     "Start over": "ابدأ من جديد",
     "Start over": "ابدأ من جديد",
     "Document copied to clipboard": "نُسخ اللصق إلى الحافظة",
     "Document copied to clipboard": "نُسخ اللصق إلى الحافظة",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "لنسخ اللصق انقر على زر النسخ أو استخدم اختصار الحافظة <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "لنسخ اللصق انقر على زر النسخ أو استخدم اختصار الحافظة <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "نسخ الرابط",
     "Copy link": "نسخ الرابط",
     "Link copied to clipboard": "نُسخ الرابط إلى الحافظة",
     "Link copied to clipboard": "نُسخ الرابط إلى الحافظة",
     "Document text": "لصق النص",
     "Document text": "لصق النص",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "السمة",
     "Theme": "السمة",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 11 - 9
i18n/bg.json

@@ -126,12 +126,12 @@
         "Този документ изтича след %d дни."
         "Този документ изтича след %d дни."
     ],
     ],
     "This document will expire in %d months.": [
     "This document will expire in %d months.": [
-        "Този документ изтича след една година.",
-        "Този документ изтича след %d години.",
-        "Този документ изтича след %d години.",
-        "Този документ изтича след %d години.",
-        "Този документ изтича след %d години.",
-        "Този документ изтича след %d години."
+        "Този документ изтича след един месец.",
+        "Този документ изтича след %d месеца.",
+        "Този документ изтича след %d месеца.",
+        "Този документ изтича след %d месеца.",
+        "Този документ изтича след %d месеца.",
+        "Този документ изтича след %d месеца."
     ],
     ],
     "Please enter the password for this document:": "Моля въведете паролата за това съдържание:",
     "Please enter the password for this document:": "Моля въведете паролата за това съдържание:",
     "Could not decrypt data (Wrong key?)": "Информацията не можеше да се дешифрова (Грешен ключ?)",
     "Could not decrypt data (Wrong key?)": "Информацията не можеше да се дешифрова (Грешен ключ?)",
@@ -151,7 +151,7 @@
     "server error or not responding": "Грешка в сървъра или не отговаря",
     "server error or not responding": "Грешка в сървъра или не отговаря",
     "Could not post comment: %s": "Публикуването на коментара Ви беше неуспешно: %s",
     "Could not post comment: %s": "Публикуването на коментара Ви беше неуспешно: %s",
     "Sending document…": "Изпращане на информацията Ви…",
     "Sending document…": "Изпращане на информацията Ви…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Вашата връзка е <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Натиснете <kbd>Ctrl</kbd>+<kbd>c</kbd> за да копирате)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Вашата връзка е <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Натиснете <kbd>%s</kbd>+<kbd>c</kbd> за да копирате)</span>",
     "Delete data": "Изтриване на информацията",
     "Delete data": "Изтриване на информацията",
     "Could not create document: %s": "Създаването на връзката ви беше неуспешно: %s",
     "Could not create document: %s": "Създаването на връзката ви беше неуспешно: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Дешифроването на информацията беше неуспешно: Ключа за декриптиране липсва във връзката (Да не сте използвали услуга за пренасочване или скъсяване на връзката, което би изрязало части от нея?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Дешифроването на информацията беше неуспешно: Ключа за декриптиране липсва във връзката (Да не сте използвали услуга за пренасочване или скъсяване на връзката, което би изрязало части от нея?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/ca.json

@@ -151,7 +151,7 @@
     "server error or not responding": "server error or not responding",
     "server error or not responding": "server error or not responding",
     "Could not post comment: %s": "No s'ha pogut publicar el comentari: %s",
     "Could not post comment: %s": "No s'ha pogut publicar el comentari: %s",
     "Sending document…": "Enviant document…",
     "Sending document…": "Enviant document…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>",
     "Delete data": "Esborrar les dades",
     "Delete data": "Esborrar les dades",
     "Could not create document: %s": "Could not create document: %s",
     "Could not create document: %s": "Could not create document: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "No es pot desxifrar la nota: falta la clau de desxifrat a l'URL (vau utilitzar un adreçament o un escurçador d'URL que elimina part de l'URL?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "No es pot desxifrar la nota: falta la clau de desxifrat a l'URL (vau utilitzar un adreçament o un escurçador d'URL que elimina part de l'URL?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 6 - 4
i18n/co.json

@@ -151,7 +151,7 @@
     "server error or not responding": "sbagliu di u servitore o u servitore ùn risponde micca",
     "server error or not responding": "sbagliu di u servitore o u servitore ùn risponde micca",
     "Could not post comment: %s": "Ùn si pò micca impustà u cummentu : %s",
     "Could not post comment: %s": "Ùn si pò micca impustà u cummentu : %s",
     "Sending document…": "Inviu di u ducumentu…",
     "Sending document…": "Inviu di u ducumentu…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "U vostru ducumentu si trova à l’indirizzu <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Appughjate nant’à <kbd>Ctrl</kbd>+<kbd>c</kbd> per cupià u liame)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "U vostru ducumentu si trova à l’indirizzu <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Appughjate nant’à <kbd>%s</kbd>+<kbd>c</kbd> per cupià u liame)</span>",
     "Delete data": "Squassà i dati",
     "Delete data": "Squassà i dati",
     "Could not create document: %s": "Ùn si pò micca creà u ducumentu : %s",
     "Could not create document: %s": "Ùn si pò micca creà u ducumentu : %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Ùn si pò micca dicifrà u ducumentu : A chjave di dicifratura hè assente in l’indirizzu. Averiate impiegatu un orientadore d’indirizzu o un riduttore chì ammuzzeghja una parte di l’indirizzu ?",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Ùn si pò micca dicifrà u ducumentu : A chjave di dicifratura hè assente in l’indirizzu. Averiate impiegatu un orientadore d’indirizzu o un riduttore chì ammuzzeghja una parte di l’indirizzu ?",
@@ -170,7 +170,7 @@
     "Markdown": "Markdown",
     "Markdown": "Markdown",
     "Download attachment": "Scaricà a pezza ghjunta",
     "Download attachment": "Scaricà a pezza ghjunta",
     "Cloned: '%s'": "Duppiatu : « %s »",
     "Cloned: '%s'": "Duppiatu : « %s »",
-    "The cloned file '%s' was attached to this document.": "U schedariu duppiatu « %s » hè statu aghjuntu à stu ducumentu.",
+    "The cloned file '%s' was attached to this document.": "U schedariu duppiatu « %s » hè statu ghjuntu à stu ducumentu.",
     "Attach a file": "Aghjunghje un schedariu",
     "Attach a file": "Aghjunghje un schedariu",
     "alternatively drag & drop a file or paste an image from the clipboard": "in alternanza, sguillà è depone un schedariu o incullà una fiura da u preme’papei",
     "alternatively drag & drop a file or paste an image from the clipboard": "in alternanza, sguillà è depone un schedariu o incullà una fiura da u preme’papei",
     "File too large, to display a preview. Please download the attachment.": "Schedariu troppu maiò per affissà una fighjulata. Scaricate a pezza ghjunta.",
     "File too large, to display a preview. Please download the attachment.": "Schedariu troppu maiò per affissà una fighjulata. Scaricate a pezza ghjunta.",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Sbagliu durante a scumpressione di u ducumentu, perchè u vostru navigatore ùn accetteghja micca WebAssembly. Ci vole à impiegà un altru navigatore per affissà stu ducumentu.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Sbagliu durante a scumpressione di u ducumentu, perchè u vostru navigatore ùn accetteghja micca WebAssembly. Ci vole à impiegà un altru navigatore per affissà stu ducumentu.",
     "Start over": "Principià torna",
     "Start over": "Principià torna",
     "Document copied to clipboard": "U ducumentu hè statu cupiatu in u preme’papei",
     "Document copied to clipboard": "U ducumentu hè statu cupiatu in u preme’papei",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Per cupià u ducumentu, appughjate nant’à u buttone di copia o impiegate l’accurtatoghju di tastera <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Per cupià u ducumentu, appughjate nant’à u buttone di copia o impiegate l’accurtatoghju di tastera <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Cupià u liame",
     "Copy link": "Cupià u liame",
     "Link copied to clipboard": "U liame hè statu cupiatu in u preme’papei",
     "Link copied to clipboard": "U liame hè statu cupiatu in u preme’papei",
     "Document text": "Testu di u ducumentu",
     "Document text": "Testu di u ducumentu",
@@ -232,5 +232,7 @@
     "Show password": "Affissà a parolla d’intesa",
     "Show password": "Affissà a parolla d’intesa",
     "Hide password": "Piattà a parolla d’intesa",
     "Hide password": "Piattà a parolla d’intesa",
     "Theme": "Tema",
     "Theme": "Tema",
-    "Copy": "Cupià"
+    "Copy": "Cupià",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/cs.json

@@ -151,7 +151,7 @@
     "server error or not responding": "Chyba na serveru nebo server neodpovídá",
     "server error or not responding": "Chyba na serveru nebo server neodpovídá",
     "Could not post comment: %s": "Nelze odeslat komentář: %s",
     "Could not post comment: %s": "Nelze odeslat komentář: %s",
     "Sending document…": "Odesílání příspěvku…",
     "Sending document…": "Odesílání příspěvku…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Váš příspěvek je <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Stiskněte <kbd>Ctrl</kbd>+<kbd>c</kbd> pro zkopírování)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Váš příspěvek je <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Stiskněte <kbd>%s</kbd>+<kbd>c</kbd> pro zkopírování)</span>",
     "Delete data": "Odstranit data",
     "Delete data": "Odstranit data",
     "Could not create document: %s": "Nepodařilo se vytvořit příspěvek: %s",
     "Could not create document: %s": "Nepodařilo se vytvořit příspěvek: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nepodařilo se dešifrovat příspěvek: V adrese chybí dešifrovací klíč (Nepoužili jste přesměrovač nebo zkracovač URL, který maže části URL?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nepodařilo se dešifrovat příspěvek: V adrese chybí dešifrovací klíč (Nepoužili jste přesměrovač nebo zkracovač URL, který maže části URL?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Chyba při dekomprimování příspěvku, váš prohlížeč nepodporuje WebAssembly. Pro zobrazení tohoto příspěvku prosím použijte jiný prohlížeč.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Chyba při dekomprimování příspěvku, váš prohlížeč nepodporuje WebAssembly. Pro zobrazení tohoto příspěvku prosím použijte jiný prohlížeč.",
     "Start over": "Začít znovu",
     "Start over": "Začít znovu",
     "Document copied to clipboard": "Dokument zkopírován do schránky",
     "Document copied to clipboard": "Dokument zkopírován do schránky",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Pro zkopírování dokumentu klikněte na tlačítko nebo použijte klávesovou zkratku schránky <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Pro zkopírování dokumentu klikněte na tlačítko nebo použijte klávesovou zkratku schránky <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Zkopírovat odkaz",
     "Copy link": "Zkopírovat odkaz",
     "Link copied to clipboard": "Odkaz zkopírován do schránky",
     "Link copied to clipboard": "Odkaz zkopírován do schránky",
     "Document text": "Text dokumentu",
     "Document text": "Text dokumentu",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Vzhled",
     "Theme": "Vzhled",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/de.json

@@ -151,7 +151,7 @@
     "server error or not responding": "Fehler auf dem Server oder keine Antwort vom Server",
     "server error or not responding": "Fehler auf dem Server oder keine Antwort vom Server",
     "Could not post comment: %s": "Konnte Kommentar nicht senden: %s",
     "Could not post comment: %s": "Konnte Kommentar nicht senden: %s",
     "Sending document…": "Sende Document…",
     "Sending document…": "Sende Document…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Dein Dokument ist unter <a id=\"pasteurl\" href=\"%s\">%s</a> zu finden <span id=\"copyhint\">(Drücke <kbd>Strg</kbd>+<kbd>c</kbd> um den Link zu kopieren)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Dein Dokument ist unter <a id=\"pasteurl\" href=\"%s\">%s</a> zu finden <span id=\"copyhint\">(Drücke <kbd>Strg</kbd>+<kbd>c</kbd> um den Link zu kopieren)</span>",
     "Delete data": "Lösche Daten",
     "Delete data": "Lösche Daten",
     "Could not create document: %s": "Dokument konnte nicht erstellt werden: %s",
     "Could not create document: %s": "Dokument konnte nicht erstellt werden: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Konnte Document 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?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Konnte Document 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?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Fehler beim Dekomprimieren des Dokumentes. Dein Browser unterstützt WebAssembly nicht. Bitte verwende einen anderen Browser, um dieses Dokument anzuzeigen.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Fehler beim Dekomprimieren des Dokumentes. Dein Browser unterstützt WebAssembly nicht. Bitte verwende einen anderen Browser, um dieses Dokument anzuzeigen.",
     "Start over": "Neues Dokument erstellen",
     "Start over": "Neues Dokument erstellen",
     "Document copied to clipboard": "Dokument wurde in Zwischenablage kopiert.",
     "Document copied to clipboard": "Dokument wurde in Zwischenablage kopiert.",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Zum Kopieren des Textes drücken Sie die Kopieren-Schaltfläche oder verwenden Sie die Tastenkombination <kbd>Strg</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Zum Kopieren des Textes drücken Sie die Kopieren-Schaltfläche oder verwenden Sie die Tastenkombination <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Verknüpfung kopieren",
     "Copy link": "Verknüpfung kopieren",
     "Link copied to clipboard": "Verknüpfung wurde in die Zwischenablage kopiert.",
     "Link copied to clipboard": "Verknüpfung wurde in die Zwischenablage kopiert.",
     "Document text": "Text des Dokuments",
     "Document text": "Text des Dokuments",
@@ -232,5 +232,7 @@
     "Show password": "Passwort anzeigen",
     "Show password": "Passwort anzeigen",
     "Hide password": "Passwort verbergen",
     "Hide password": "Passwort verbergen",
     "Theme": "Design",
     "Theme": "Design",
-    "Copy": "Kopieren"
+    "Copy": "Kopieren",
+    "Cmd": "Cmd",
+    "Ctrl": "Strg"
 }
 }

+ 5 - 3
i18n/el.json

@@ -151,7 +151,7 @@
     "server error or not responding": "Πρόβλημα του διακομιστή ή δεν υπάρχει απάντηση",
     "server error or not responding": "Πρόβλημα του διακομιστή ή δεν υπάρχει απάντηση",
     "Could not post comment: %s": "Δεν ήταν δυνατή η δημοσίευση του σχολίου: %s",
     "Could not post comment: %s": "Δεν ήταν δυνατή η δημοσίευση του σχολίου: %s",
     "Sending document…": "Η επικόλληση αποστέλλεται…",
     "Sending document…": "Η επικόλληση αποστέλλεται…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Η επικόλλησή σας είναι <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Πληκτρολογήστε <kbd>Ctrl</kbd>+<kbd>c</kbd> για αντιγραφή)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Η επικόλλησή σας είναι <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Πληκτρολογήστε <kbd>%s</kbd>+<kbd>c</kbd> για αντιγραφή)</span>",
     "Delete data": "Διαγραφή δεδομένων",
     "Delete data": "Διαγραφή δεδομένων",
     "Could not create document: %s": "Δεν ήταν δυνατή η δημιουργία επικόλλησης: %s",
     "Could not create document: %s": "Δεν ήταν δυνατή η δημιουργία επικόλλησης: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Δεν ήταν δυνατή η αποκρυπτογράφηση της επικόλλησης: Το κλειδί αποκρυπτογράφησης λείπει από τον σύνδεσμο (Μήπως χρησιμοποιήσατε ανακατεύθυνση συνδέσμου ή υπηρεσία συντόμευσης συνδέσμου;)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Δεν ήταν δυνατή η αποκρυπτογράφηση της επικόλλησης: Το κλειδί αποκρυπτογράφησης λείπει από τον σύνδεσμο (Μήπως χρησιμοποιήσατε ανακατεύθυνση συνδέσμου ή υπηρεσία συντόμευσης συνδέσμου;)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Σφάλμα αποσυμπίεσης της επικόλλησης, ο περιηγητής σας δεν υποστηρίζει WebAssembly. Παρακαλούμε χρησιμοποιήστε έναν άλλο περιηγητή για να δείτε αυτή την επικόλληση.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Σφάλμα αποσυμπίεσης της επικόλλησης, ο περιηγητής σας δεν υποστηρίζει WebAssembly. Παρακαλούμε χρησιμοποιήστε έναν άλλο περιηγητή για να δείτε αυτή την επικόλληση.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/en.json

@@ -151,7 +151,7 @@
     "server error or not responding": "server error or not responding",
     "server error or not responding": "server error or not responding",
     "Could not post comment: %s": "Could not post comment: %s",
     "Could not post comment: %s": "Could not post comment: %s",
     "Sending document…": "Sending document…",
     "Sending document…": "Sending document…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>",
     "Delete data": "Delete data",
     "Delete data": "Delete data",
     "Could not create document: %s": "Could not create document: %s",
     "Could not create document: %s": "Could not create document: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/es.json

@@ -151,7 +151,7 @@
     "server error or not responding": "Error del servidor o el servidor no responde",
     "server error or not responding": "Error del servidor o el servidor no responde",
     "Could not post comment: %s": "No fue posible publicar comentario: %s",
     "Could not post comment: %s": "No fue posible publicar comentario: %s",
     "Sending document…": "Enviando documento…",
     "Sending document…": "Enviando documento…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Su texto está en <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Presione <kbd>Ctrl</kbd>+<kbd>c</kbd> para copiar)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Su texto está en <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Presione <kbd>%s</kbd>+<kbd>c</kbd> para copiar)</span>",
     "Delete data": "Eliminar datos",
     "Delete data": "Eliminar datos",
     "Could not create document: %s": "No fue posible crear el archivo: %s",
     "Could not create document: %s": "No fue posible crear el archivo: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "No es posible descifrar el documento: Falta la clave de descifrado en la URL (¿Utilizó un redirector o un acortador de URL que quite parte de la URL?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "No es posible descifrar el documento: Falta la clave de descifrado en la URL (¿Utilizó un redirector o un acortador de URL que quite parte de la URL?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error al descomprimir el documento. Su navegador no es compatible con WebAssembly. Utilice otro navegador para ver este documento.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error al descomprimir el documento. Su navegador no es compatible con WebAssembly. Utilice otro navegador para ver este documento.",
     "Start over": "Empezar de nuevo",
     "Start over": "Empezar de nuevo",
     "Document copied to clipboard": "Documento copiado al portapapeles",
     "Document copied to clipboard": "Documento copiado al portapapeles",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Para copiar el documento presione el botón copiar o use el atajo del portapapeles <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Para copiar el documento presione el botón copiar o use el atajo del portapapeles <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copiar enlace",
     "Copy link": "Copiar enlace",
     "Link copied to clipboard": "Enlace copiado al portapapeles",
     "Link copied to clipboard": "Enlace copiado al portapapeles",
     "Document text": "Texto del documento",
     "Document text": "Texto del documento",
@@ -232,5 +232,7 @@
     "Show password": "Mostrar contraseña",
     "Show password": "Mostrar contraseña",
     "Hide password": "Ocultar contraseña",
     "Hide password": "Ocultar contraseña",
     "Theme": "Tema",
     "Theme": "Tema",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/et.json

@@ -151,7 +151,7 @@
     "server error or not responding": "serveri viga või ei vasta",
     "server error or not responding": "serveri viga või ei vasta",
     "Could not post comment: %s": "Ei suutnud kommentaari postitada: %s",
     "Could not post comment: %s": "Ei suutnud kommentaari postitada: %s",
     "Sending document…": "Kleepe saatmine…",
     "Sending document…": "Kleepe saatmine…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Sinu kleebe on <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Kopeerimiseks vajuta <kbd>Ctrl</kbd>+<kbd>c</kbd>)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Sinu kleebe on <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Kopeerimiseks vajuta <kbd>%s</kbd>+<kbd>c</kbd>)</span>",
     "Delete data": "Kustuta andmed",
     "Delete data": "Kustuta andmed",
     "Could not create document: %s": "Ei suutnud kleebet luua: %s",
     "Could not create document: %s": "Ei suutnud kleebet luua: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Ei suutnud kleebet dekrüpteerida: Dekrüpteerimisvõti on URL-ist puudu (Kas kasutasid ümbersuunajat või URL-i lühendajat, mis eemaldab osa URL-ist?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Ei suutnud kleebet dekrüpteerida: Dekrüpteerimisvõti on URL-ist puudu (Kas kasutasid ümbersuunajat või URL-i lühendajat, mis eemaldab osa URL-ist?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Alusta uuesti",
     "Start over": "Alusta uuesti",
     "Document copied to clipboard": "Kleebe kopeeriti lõikelauale",
     "Document copied to clipboard": "Kleebe kopeeriti lõikelauale",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Kopeeri link",
     "Copy link": "Kopeeri link",
     "Link copied to clipboard": "Link kopeeriti lõikelauale",
     "Link copied to clipboard": "Link kopeeriti lõikelauale",
     "Document text": "Kleebi tekst",
     "Document text": "Kleebi tekst",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Teema",
     "Theme": "Teema",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/fa.json

@@ -151,7 +151,7 @@
     "server error or not responding": "خطای سرور یا عدم پاسخ‌دهی",
     "server error or not responding": "خطای سرور یا عدم پاسخ‌دهی",
     "Could not post comment: %s": "ارسال نظر ممکن نیست: %s",
     "Could not post comment: %s": "ارسال نظر ممکن نیست: %s",
     "Sending document…": "در حال ارسال سند…",
     "Sending document…": "در حال ارسال سند…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "سند شما <a id=\"pasteurl\" href=\"%s\">%s</a> است <span id=\"copyhint\">(برای کپی‌کردن <kbd>Ctrl</kbd>+<kbd>c</kbd> را فشار دهید)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "سند شما <a id=\"pasteurl\" href=\"%s\">%s</a> است <span id=\"copyhint\">(برای کپی‌کردن <kbd>%s</kbd>+<kbd>c</kbd> را فشار دهید)</span>",
     "Delete data": "حذف داده‌ها",
     "Delete data": "حذف داده‌ها",
     "Could not create document: %s": "ایجاد سند ممکن نیست: %s",
     "Could not create document: %s": "ایجاد سند ممکن نیست: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "رمزگشایی سند ممکن نیست: کلید رمزگشایی در URL موجود نیست (آیا از ریدایرکت یا کوتاه‌کنندهٔ URL استفاده کرده‌اید که بخشی از URL را حذف می‌کند؟)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "رمزگشایی سند ممکن نیست: کلید رمزگشایی در URL موجود نیست (آیا از ریدایرکت یا کوتاه‌کنندهٔ URL استفاده کرده‌اید که بخشی از URL را حذف می‌کند؟)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "خطا در رفع فشرده‌سازی سند، مرورگر شما از WebAssembly پشتیبانی نمی‌کند. لطفاً برای مشاهدهٔ این سند از مرورگر دیگری استفاده کنید.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "خطا در رفع فشرده‌سازی سند، مرورگر شما از WebAssembly پشتیبانی نمی‌کند. لطفاً برای مشاهدهٔ این سند از مرورگر دیگری استفاده کنید.",
     "Start over": "شروع دوباره",
     "Start over": "شروع دوباره",
     "Document copied to clipboard": "سند در کلیپ‌بورد کپی شد",
     "Document copied to clipboard": "سند در کلیپ‌بورد کپی شد",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "برای کپی‌کردن سند، دکمهٔ کپی را فشار دهید یا از میانبر کلیپ‌بورد <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd> استفاده کنید",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "برای کپی‌کردن سند، دکمهٔ کپی را فشار دهید یا از میانبر کلیپ‌بورد <kbd>%s</kbd>+<kbd>c</kbd> استفاده کنید",
     "Copy link": "کپی لینک",
     "Copy link": "کپی لینک",
     "Link copied to clipboard": "لینک در کلیپ‌بورد کپی شد",
     "Link copied to clipboard": "لینک در کلیپ‌بورد کپی شد",
     "Document text": "متن سند",
     "Document text": "متن سند",
@@ -232,5 +232,7 @@
     "Show password": "نمایش رمز عبور",
     "Show password": "نمایش رمز عبور",
     "Hide password": "مخفی‌کردن رمز عبور",
     "Hide password": "مخفی‌کردن رمز عبور",
     "Theme": "تم",
     "Theme": "تم",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/fi.json

@@ -151,7 +151,7 @@
     "server error or not responding": "palvelinvirhe tai palvelin ei vastaa",
     "server error or not responding": "palvelinvirhe tai palvelin ei vastaa",
     "Could not post comment: %s": "Kommenttia ei voitu lähettää: %s",
     "Could not post comment: %s": "Kommenttia ei voitu lähettää: %s",
     "Sending document…": "Lähetetään pastea…",
     "Sending document…": "Lähetetään pastea…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Pastesi on <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Paina <kbd>Ctrl</kbd>+<kbd>c</kbd> kopioidaksesi)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Pastesi on <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Paina <kbd>%s</kbd>+<kbd>c</kbd> kopioidaksesi)</span>",
     "Delete data": "Poista data",
     "Delete data": "Poista data",
     "Could not create document: %s": "Pastea ei voitu luoda: %s",
     "Could not create document: %s": "Pastea ei voitu luoda: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Pastea ei voitu purkaa: Purkausavain puuttuu URL:stä (Käytitkö uudelleenohjaajaa tai URL-lyhentäjää joka poistaa osan URL:stä?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Pastea ei voitu purkaa: Purkausavain puuttuu URL:stä (Käytitkö uudelleenohjaajaa tai URL-lyhentäjää joka poistaa osan URL:stä?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Virhe pasten purkamisessa, selaimesi ei tue WebAssemblyä. Ole hyvä ja käytä toista selainta nähdäksesi tämä asiakirja.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Virhe pasten purkamisessa, selaimesi ei tue WebAssemblyä. Ole hyvä ja käytä toista selainta nähdäksesi tämä asiakirja.",
     "Start over": "Aloita alusta",
     "Start over": "Aloita alusta",
     "Document copied to clipboard": "Asiakirja kopioitu leikepöydälle",
     "Document copied to clipboard": "Asiakirja kopioitu leikepöydälle",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Voit kopioida pasten painamalla kopioi-painiketta tai käyttämällä leikepöydän oikotietä <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Voit kopioida pasten painamalla kopioi-painiketta tai käyttämällä leikepöydän oikotietä <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Kopioi linkki",
     "Copy link": "Kopioi linkki",
     "Link copied to clipboard": "Linkki kopioitu leikepöydälle",
     "Link copied to clipboard": "Linkki kopioitu leikepöydälle",
     "Document text": "Liitä teksti",
     "Document text": "Liitä teksti",
@@ -232,5 +232,7 @@
     "Show password": "Näytä salasana",
     "Show password": "Näytä salasana",
     "Hide password": "Piilota salasana",
     "Hide password": "Piilota salasana",
     "Theme": "Teema",
     "Theme": "Teema",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/fr.json

@@ -151,7 +151,7 @@
     "server error or not responding": "Le serveur ne répond pas ou a rencontré une erreur",
     "server error or not responding": "Le serveur ne répond pas ou a rencontré une erreur",
     "Could not post comment: %s": "Impossible de poster le commentaire : %s",
     "Could not post comment: %s": "Impossible de poster le commentaire : %s",
     "Sending document…": "Envoi du document…",
     "Sending document…": "Envoi du document…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Votre document est disponible à l'adresse <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Appuyez sur <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd> pour copier)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Votre document est disponible à l'adresse <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Appuyez sur <kbd>%s</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd> pour copier)</span>",
     "Delete data": "Supprimer les données du document",
     "Delete data": "Supprimer les données du document",
     "Could not create document: %s": "Impossible de créer le document : %s",
     "Could not create document: %s": "Impossible de créer le document : %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Impossible de déchiffrer le document : Clé de déchiffrement manquante dans l'URL (Avez-vous utilisé un redirecteur ou un site de réduction d'URL qui supprime une partie de l'URL ?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Impossible de déchiffrer le document : Clé de déchiffrement manquante dans l'URL (Avez-vous utilisé un redirecteur ou un site de réduction d'URL qui supprime une partie de l'URL ?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Erreur lors de la décompression du document, votre navigateur ne supporte pas WebAssembly. Veuillez utiliser un autre navigateur pour voir ce document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Erreur lors de la décompression du document, votre navigateur ne supporte pas WebAssembly. Veuillez utiliser un autre navigateur pour voir ce document.",
     "Start over": "Recommencer",
     "Start over": "Recommencer",
     "Document copied to clipboard": "Document copié dans le presse-papier",
     "Document copied to clipboard": "Document copié dans le presse-papier",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Pour copier appuyer sur le bouton Copier le lien ou utiliser le raccourci <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Pour copier appuyer sur le bouton Copier le lien ou utiliser le raccourci <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copier le lien",
     "Copy link": "Copier le lien",
     "Link copied to clipboard": "Lien copié dans le presse-papier",
     "Link copied to clipboard": "Lien copié dans le presse-papier",
     "Document text": "Texte du document",
     "Document text": "Texte du document",
@@ -232,5 +232,7 @@
     "Show password": "Afficher le mot de passe",
     "Show password": "Afficher le mot de passe",
     "Hide password": "Cacher le mot de passe",
     "Hide password": "Cacher le mot de passe",
     "Theme": "Thème",
     "Theme": "Thème",
-    "Copy": "Copier"
+    "Copy": "Copier",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/he.json

@@ -151,7 +151,7 @@
     "server error or not responding": "שגיאת שרת או שהשרת לא מגיב",
     "server error or not responding": "שגיאת שרת או שהשרת לא מגיב",
     "Could not post comment: %s": "לא ניתן לפרסם תגובה: %s",
     "Could not post comment: %s": "לא ניתן לפרסם תגובה: %s",
     "Sending document…": "ההדבקה נשלחת…",
     "Sending document…": "ההדבקה נשלחת…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "ההדבקה שלך היא <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(יש ללחוץ <kbd>Ctrl</kbd>+<kbd>c</kbd> כדי להעתיק)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "ההדבקה שלך היא <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(יש ללחוץ <kbd>%s</kbd>+<kbd>c</kbd> כדי להעתיק)</span>",
     "Delete data": "מחיקת נתונים",
     "Delete data": "מחיקת נתונים",
     "Could not create document: %s": "לא ניתן ליצור הדבקה: %s",
     "Could not create document: %s": "לא ניתן ליצור הדבקה: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "לא ניתן לפענח הדבקה: מפתח הפענוח חסר בכתובת (השתמשת במערכת הפנייה או מקצר כתובות שחותכים חלק מהכתובת?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "לא ניתן לפענח הדבקה: מפתח הפענוח חסר בכתובת (השתמשת במערכת הפנייה או מקצר כתובות שחותכים חלק מהכתובת?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "שגיאה בהפקת ההדבקה, הדפדפן שלך לא תומך ב-WebAssembly. אנא השתמש בדפדפן אחר כדי לצפות בהדבקה זו.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "שגיאה בהפקת ההדבקה, הדפדפן שלך לא תומך ב-WebAssembly. אנא השתמש בדפדפן אחר כדי לצפות בהדבקה זו.",
     "Start over": "להתחיל מחדש",
     "Start over": "להתחיל מחדש",
     "Document copied to clipboard": "ההדבקה הועתקה ללוח",
     "Document copied to clipboard": "ההדבקה הועתקה ללוח",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "על מנת להעתיק את ההדבקה, לחץ על כפתור ההעתקה או השתמש בקיצור הדרך ללוח <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "על מנת להעתיק את ההדבקה, לחץ על כפתור ההעתקה או השתמש בקיצור הדרך ללוח <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "העתק קישור",
     "Copy link": "העתק קישור",
     "Link copied to clipboard": "הקישור הועתק ללוח",
     "Link copied to clipboard": "הקישור הועתק ללוח",
     "Document text": "הדבק טקסט",
     "Document text": "הדבק טקסט",
@@ -232,5 +232,7 @@
     "Show password": "הצג סיסמה",
     "Show password": "הצג סיסמה",
     "Hide password": "הסתר סיסמה",
     "Hide password": "הסתר סיסמה",
     "Theme": "נושא",
     "Theme": "נושא",
-    "Copy": "העתק"
+    "Copy": "העתק",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/hi.json

@@ -151,7 +151,7 @@
     "server error or not responding": "server error or not responding",
     "server error or not responding": "server error or not responding",
     "Could not post comment: %s": "Could not post comment: %s",
     "Could not post comment: %s": "Could not post comment: %s",
     "Sending document…": "Sending document…",
     "Sending document…": "Sending document…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>",
     "Delete data": "Delete data",
     "Delete data": "Delete data",
     "Could not create document: %s": "Could not create document: %s",
     "Could not create document: %s": "Could not create document: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/hu.json

@@ -151,7 +151,7 @@
     "server error or not responding": "A szerveren hiba lépett fel vagy nem válaszol.",
     "server error or not responding": "A szerveren hiba lépett fel vagy nem válaszol.",
     "Could not post comment: %s": "Nem tudtuk beküldeni a hozzászólást: %s",
     "Could not post comment: %s": "Nem tudtuk beküldeni a hozzászólást: %s",
     "Sending document…": "Bejegyzés elküldése...",
     "Sending document…": "Bejegyzés elküldése...",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "A bejegyzésed a <a id=\"pasteurl\" href=\"%s\">%s</a> címen elérhető. <span id=\"copyhint\"> <kbd>Ctrl</kbd>+<kbd>c</kbd>-vel tudod vágólapra másolni.</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "A bejegyzésed a <a id=\"pasteurl\" href=\"%s\">%s</a> címen elérhető. <span id=\"copyhint\"> <kbd>%s</kbd>+<kbd>c</kbd>-vel tudod vágólapra másolni.</span>",
     "Delete data": "Adat törlése",
     "Delete data": "Adat törlése",
     "Could not create document: %s": "Nem tudtuk létrehozni a bejegyzést: %s",
     "Could not create document: %s": "Nem tudtuk létrehozni a bejegyzést: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nem tudjuk visszafejteni a bejegyzést: a dekódoláshoz szükséges kulcs hiányzik a címből. Talán URL rövidítőt használtál, amely azt kivágta belőle?",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nem tudjuk visszafejteni a bejegyzést: a dekódoláshoz szükséges kulcs hiányzik a címből. Talán URL rövidítőt használtál, amely azt kivágta belőle?",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Újrakezdés",
     "Start over": "Újrakezdés",
     "Document copied to clipboard": "Bejegyzés másolva",
     "Document copied to clipboard": "Bejegyzés másolva",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Másoláshoz használd a <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd> billentyűkombinációt",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Másoláshoz használd a <kbd>%s</kbd>+<kbd>c</kbd> billentyűkombinációt",
     "Copy link": "Link másolása",
     "Copy link": "Link másolása",
     "Link copied to clipboard": "Link másolva",
     "Link copied to clipboard": "Link másolva",
     "Document text": "Bejegyzés szövege",
     "Document text": "Bejegyzés szövege",
@@ -232,5 +232,7 @@
     "Show password": "Jelszó megjelenítése",
     "Show password": "Jelszó megjelenítése",
     "Hide password": "Jelszó elrejtése",
     "Hide password": "Jelszó elrejtése",
     "Theme": "Téma",
     "Theme": "Téma",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/id.json

@@ -151,7 +151,7 @@
     "server error or not responding": "kesalahan server atau server tidak merespon",
     "server error or not responding": "kesalahan server atau server tidak merespon",
     "Could not post comment: %s": "Tidak dapat memposting komentar: %s",
     "Could not post comment: %s": "Tidak dapat memposting komentar: %s",
     "Sending document…": "Mengirim document…",
     "Sending document…": "Mengirim document…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Document Anda adalah <a id=\"pasteurl\" href=\"%s\">%s</a><span id=\"copyhint\">(Tekan <kbd>Ctrl</kbd>+<kbd>c</kbd> untuk menyalin)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Document Anda adalah <a id=\"pasteurl\" href=\"%s\">%s</a><span id=\"copyhint\">(Tekan <kbd>%s</kbd>+<kbd>c</kbd> untuk menyalin)</span>",
     "Delete data": "Hapus data",
     "Delete data": "Hapus data",
     "Could not create document: %s": "Tidak dapat membuat document: %s",
     "Could not create document: %s": "Tidak dapat membuat document: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Tidak dapat mendekripsi document: Kunci dekripsi tidak ada di URL (Apakah Anda menggunakan redirector atau penyingkat URL yang menghapus bagian dari URL?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Tidak dapat mendekripsi document: Kunci dekripsi tidak ada di URL (Apakah Anda menggunakan redirector atau penyingkat URL yang menghapus bagian dari URL?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/it.json

@@ -151,7 +151,7 @@
     "server error or not responding": "errore o mancata risposta dal server",
     "server error or not responding": "errore o mancata risposta dal server",
     "Could not post comment: %s": "Impossibile inviare il commento: %s",
     "Could not post comment: %s": "Impossibile inviare il commento: %s",
     "Sending document…": "Messaggio in fase di invio…",
     "Sending document…": "Messaggio in fase di invio…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Il tuo messaggio è qui: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Premi <kbd>Ctrl</kbd>+<kbd>c</kbd> (Windows) o [Cmd]+[c] (Mac) per copiare il link)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Il tuo messaggio è qui: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Premi <kbd>%s</kbd>+<kbd>c</kbd> (Windows) o [Cmd]+[c] (Mac) per copiare il link)</span>",
     "Delete data": "Cancella i dati",
     "Delete data": "Cancella i dati",
     "Could not create document: %s": "Non riesco a creare il messaggio: %s",
     "Could not create document: %s": "Non riesco a creare il messaggio: %s",
     "Cannot decrypt document: 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?)",
     "Cannot decrypt document: 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?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Errore nella decompressione dell messaggio, il tuo browser non supporta WebAssembly. Utilizza un altro browser per visualizzare questo messaggio.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Errore nella decompressione dell messaggio, il tuo browser non supporta WebAssembly. Utilizza un altro browser per visualizzare questo messaggio.",
     "Start over": "Ricominciare",
     "Start over": "Ricominciare",
     "Document copied to clipboard": "Messaggio copiato",
     "Document copied to clipboard": "Messaggio copiato",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Per copiare il documento premere sul pulsante di copia o utilizzare la scorciatoia <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Per copiare il documento premere sul pulsante di copia o utilizzare la scorciatoia <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copia il link",
     "Copy link": "Copia il link",
     "Link copied to clipboard": "Link copiato negli appunti",
     "Link copied to clipboard": "Link copiato negli appunti",
     "Document text": "Testo del messaggio",
     "Document text": "Testo del messaggio",
@@ -232,5 +232,7 @@
     "Show password": "Mostra password",
     "Show password": "Mostra password",
     "Hide password": "Nascondi password",
     "Hide password": "Nascondi password",
     "Theme": "Tema",
     "Theme": "Tema",
-    "Copy": "Copiare"
+    "Copy": "Copiare",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/ja.json

@@ -151,7 +151,7 @@
     "server error or not responding": "サーバーエラーまたは応答しません",
     "server error or not responding": "サーバーエラーまたは応答しません",
     "Could not post comment: %s": "コメントを投稿できませんでした:%s",
     "Could not post comment: %s": "コメントを投稿できませんでした:%s",
     "Sending document…": "ペーストを送信しています…",
     "Sending document…": "ペーストを送信しています…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "ペーストは<a id=\"pasteurl\" href=\"%s\">%s</a>です<span id=\"copyhint\">(コピーするには<kbd>Ctrl</kbd>+<kbd>c</kbd>を押してください)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "ペーストは<a id=\"pasteurl\" href=\"%s\">%s</a>です<span id=\"copyhint\">(コピーするには<kbd>%s</kbd>+<kbd>c</kbd>を押してください)</span>",
     "Delete data": "データを削除",
     "Delete data": "データを削除",
     "Could not create document: %s": "ペーストを作成できませんでした:%s",
     "Could not create document: %s": "ペーストを作成できませんでした:%s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "ペーストを復号化できません:復号化キーがURLにありません(URLの一部を削除するリダイレクト機能またはURLの短縮アプリケーションを使いましたか?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "ペーストを復号化できません:復号化キーがURLにありません(URLの一部を削除するリダイレクト機能またはURLの短縮アプリケーションを使いましたか?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/jbo.json

@@ -151,7 +151,7 @@
     "server error or not responding": "server error or not responding",
     "server error or not responding": "server error or not responding",
     "Could not post comment: %s": "Could not post comment: %s",
     "Could not post comment: %s": "Could not post comment: %s",
     "Sending document…": "ca'o benji le papri…",
     "Sending document…": "ca'o benji le papri…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>",
     "Delete data": "vimcu loi datni",
     "Delete data": "vimcu loi datni",
     "Could not create document: %s": "Could not create document: %s",
     "Could not create document: %s": "Could not create document: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "sorcu fukpi le urli",
     "Copy link": "sorcu fukpi le urli",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "se dokcu",
     "Document text": "se dokcu",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "jvinu",
     "Theme": "jvinu",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 200 - 198
i18n/ko.json

@@ -1,160 +1,160 @@
 {
 {
     "PrivateBin": "PrivateBin",
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.",
-    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "More information on the <a href=\"https://privatebin.info/\">project page</a>.",
-    "Because ignorance is bliss": "Because ignorance is bliss",
-    "Document does not exist, has expired or has been deleted.": "Document does not exist, has expired or has been deleted.",
-    "%s requires php %s or above to work. Sorry.": "%s requires php %s or above to work. Sorry.",
-    "%s requires configuration section [%s] to be present in configuration file.": "%s requires configuration section [%s] to be present in configuration file.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s은 서버가 저장된 데이터에 대해 전혀 알 수 없는 미니멀한 오픈소스 온라인 텍스트 공유 플랫폼(페이스트빈)입니다. 데이터는 256비트 AES를 사용하여 %s브라우저에서%s 암호화/복호화됩니다.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "자세한 정보는 <a href=\"https://privatebin.info/\">프로젝트 페이지</a>에서 확인하세요.",
+    "Because ignorance is bliss": "모르는 게 약이니까요",
+    "Document does not exist, has expired or has been deleted.": "존재하지 않거나 만료 또는 삭제된 문서입니다.",
+    "%s requires php %s or above to work. Sorry.": "%s을(를) 실행하려면 PHP %s 이상의 버전이 필요합니다. 죄송합니다.",
+    "%s requires configuration section [%s] to be present in configuration file.": "%s을(를) 실행하려면 설정 파일에 [%s] 설정 섹션이 있어야 합니다.",
     "Please wait %d seconds between each post.": [
     "Please wait %d seconds between each post.": [
-        "Please wait %d second between each post. (singular)",
-        "Please wait %d seconds between each post. (1st plural)",
-        "Please wait %d seconds between each post. (2nd plural)",
-        "Please wait %d seconds between each post. (3rd plural)",
-        "Please wait %d seconds between each post. (4th plural)",
-        "Please wait %d seconds between each post. (5th plural)"
+        "다음 게시글을 작성할 때까지 %d초 동안 기다려 주세요.",
+        "다음 게시글을 작성할 때까지 %d초 동안 기다려 주세요.",
+        "다음 게시글을 작성할 때까지 %d초 동안 기다려 주세요.",
+        "다음 게시글을 작성할 때까지 %d초 동안 기다려 주세요.",
+        "다음 게시글을 작성할 때까지 %d초 동안 기다려 주세요.",
+        "다음 게시글을 작성할 때까지 %d초 동안 기다려 주세요."
     ],
     ],
-    "Document is limited to %s of encrypted data.": "Document is limited to %s of encrypted data.",
-    "Invalid data.": "Invalid data.",
-    "You are unlucky. Try again.": "You are unlucky. Try again.",
-    "Error saving comment. Sorry.": "Error saving comment. Sorry.",
-    "Error saving document. Sorry.": "Error saving document. Sorry.",
-    "Invalid document ID.": "Invalid document ID.",
-    "Document is not of burn-after-reading type.": "Document is not of burn-after-reading type.",
-    "Wrong deletion token. Document was not deleted.": "Wrong deletion token. Document was not deleted.",
-    "Document was properly deleted.": "Document was properly deleted.",
-    "JavaScript is required for %s to work. Sorry for the inconvenience.": "JavaScript is required for %s to work. Sorry for the inconvenience.",
-    "%s requires a modern browser to work.": "%s requires a modern browser to work.",
-    "New": "New",
-    "Create": "Create",
-    "Clone": "Clone",
-    "Raw text": "Raw text",
-    "Expires": "Expires",
-    "Burn after reading": "Burn after reading",
-    "Open discussion": "Open discussion",
-    "Password (recommended)": "Password (recommended)",
-    "Discussion": "Discussion",
-    "Toggle navigation": "Toggle navigation",
+    "Document is limited to %s of encrypted data.": "문서 크기는 암호화된 데이터 기준 %s로 제한됩니다.",
+    "Invalid data.": "유효하지 않은 데이터입니다.",
+    "You are unlucky. Try again.": "운이 나빴네요. 다시 시도해 주세요.",
+    "Error saving comment. Sorry.": "댓글을 저장하는 중 오류가 발생했습니다. 죄송합니다.",
+    "Error saving document. Sorry.": "문서를 저장하는 중 오류가 발생했습니다. 죄송합니다.",
+    "Invalid document ID.": "유효하지 않은 문서 ID입니다.",
+    "Document is not of burn-after-reading type.": "이 문서는 '읽은 후 삭제' 설정이 적용되지 않았습니다.",
+    "Wrong deletion token. Document was not deleted.": "삭제 토큰이 일치하지 않습니다. 문서가 삭제되지 않았습니다.",
+    "Document was properly deleted.": "문서가 정상적으로 삭제되었습니다.",
+    "JavaScript is required for %s to work. Sorry for the inconvenience.": "%s을(를) 실행하려면 JavaScript가 필요합니다. 이용에 불편을 드려 죄송합니다.",
+    "%s requires a modern browser to work.": "%s을(를) 실행하려면 최신 브라우저가 필요합니다.",
+    "New": "새 문서",
+    "Create": "생성",
+    "Clone": "복제",
+    "Raw text": "원본 텍스트",
+    "Expires": "만료 기간",
+    "Burn after reading": "읽은 후 삭제",
+    "Open discussion": "댓글 허용",
+    "Password (recommended)": "비밀번호 (권장)",
+    "Discussion": "댓글",
+    "Toggle navigation": "네비게이션 토글",
     "%d seconds": [
     "%d seconds": [
-        "%d second (singular)",
-        "%d seconds (1st plural)",
-        "%d seconds (2nd plural)",
-        "%d seconds (3rd plural)",
-        "%d seconds (4th plural)",
-        "%d seconds (5th plural)"
+        "%d초",
+        "%d초",
+        "%d초",
+        "%d초",
+        "%d초",
+        "%d초"
     ],
     ],
     "%d minutes": [
     "%d minutes": [
-        "%d minute (singular)",
-        "%d minutes (1st plural)",
-        "%d minutes (2nd plural)",
-        "%d minutes (3rd plural)",
-        "%d minutes (4th plural)",
-        "%d minutes (5th plural)"
+        "%d분",
+        "%d분",
+        "%d분",
+        "%d분",
+        "%d분",
+        "%d분"
     ],
     ],
     "%d hours": [
     "%d hours": [
-        "%d hour (singular)",
-        "%d hours (1st plural)",
-        "%d hours (2nd plural)",
-        "%d hours (3rd plural)",
-        "%d hours (4th plural)",
-        "%d hours (5th plural)"
+        "%d시간",
+        "%d시간",
+        "%d시간",
+        "%d시간",
+        "%d시간",
+        "%d시간"
     ],
     ],
     "%d days": [
     "%d days": [
-        "%d day (singular)",
-        "%d days (1st plural)",
-        "%d days (2nd plural)",
-        "%d days (3rd plural)",
-        "%d days (4th plural)",
-        "%d days (5th plural)"
+        "%d일",
+        "%d일",
+        "%d일",
+        "%d일",
+        "%d일",
+        "%d일"
     ],
     ],
     "%d weeks": [
     "%d weeks": [
-        "%d week (singular)",
-        "%d weeks (1st plural)",
-        "%d weeks (2nd plural)",
-        "%d weeks (3rd plural)",
-        "%d weeks (4th plural)",
-        "%d weeks (5th plural)"
+        "%d주",
+        "%d주",
+        "%d주",
+        "%d주",
+        "%d주",
+        "%d주"
     ],
     ],
     "%d months": [
     "%d months": [
-        "%d month (singular)",
-        "%d months (1st plural)",
-        "%d months (2nd plural)",
-        "%d months (3rd plural)",
-        "%d months (4th plural)",
-        "%d months (5th plural)"
+        "%d개월",
+        "%d개월",
+        "%d개월",
+        "%d개월",
+        "%d개월",
+        "%d개월"
     ],
     ],
     "%d years": [
     "%d years": [
-        "%d year (singular)",
-        "%d years (1st plural)",
-        "%d years (2nd plural)",
-        "%d years (3rd plural)",
-        "%d years (4th plural)",
-        "%d years (5th plural)"
+        "%d년",
+        "%d년",
+        "%d년",
+        "%d년",
+        "%d년",
+        "%d년"
     ],
     ],
-    "Never": "Never",
-    "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.",
+    "Never": "만료 안 함",
+    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "주의: 이 서비스는 테스트용입니다. 데이터는 언제든지 삭제될 수 있습니다. 시스템의 무단 남용을 금지합니다.",
     "This document will expire in %d seconds.": [
     "This document will expire in %d seconds.": [
-        "This document will expire in %d second. (singular)",
-        "This document will expire in %d seconds. (1st plural)",
-        "This document will expire in %d seconds. (2nd plural)",
-        "This document will expire in %d seconds. (3rd plural)",
-        "This document will expire in %d seconds. (4th plural)",
-        "This document will expire in %d seconds. (5th plural)"
+        "이 문서는 %d초 후에 만료됩니다.",
+        "이 문서는 %d초 후에 만료됩니다.",
+        "이 문서는 %d초 후에 만료됩니다.",
+        "이 문서는 %d초 후에 만료됩니다.",
+        "이 문서는 %d초 후에 만료됩니다.",
+        "이 문서는 %d초 후에 만료됩니다."
     ],
     ],
     "This document will expire in %d minutes.": [
     "This document will expire in %d minutes.": [
-        "This document will expire in %d minute. (singular)",
-        "This document will expire in %d minutes. (1st plural)",
-        "This document will expire in %d minutes. (2nd plural)",
-        "This document will expire in %d minutes. (3rd plural)",
-        "This document will expire in %d minutes. (4th plural)",
-        "This document will expire in %d minutes. (5th plural)"
+        "이 문서는 %d분 후에 만료됩니다.",
+        "이 문서는 %d분 후에 만료됩니다.",
+        "이 문서는 %d분 후에 만료됩니다.",
+        "이 문서는 %d분 후에 만료됩니다.",
+        "이 문서는 %d분 후에 만료됩니다.",
+        "이 문서는 %d분 후에 만료됩니다."
     ],
     ],
     "This document will expire in %d hours.": [
     "This document will expire in %d hours.": [
-        "This document will expire in %d hour. (singular)",
-        "This document will expire in %d hours. (1st plural)",
-        "This document will expire in %d hours. (2nd plural)",
-        "This document will expire in %d hours. (3rd plural)",
-        "This document will expire in %d hours. (4th plural)",
-        "This document will expire in %d hours. (5th plural)"
+        "이 문서는 %d시간 후에 만료됩니다.",
+        "이 문서는 %d시간 후에 만료됩니다.",
+        "이 문서는 %d시간 후에 만료됩니다.",
+        "이 문서는 %d시간 후에 만료됩니다.",
+        "이 문서는 %d시간 후에 만료됩니다.",
+        "이 문서는 %d시간 후에 만료됩니다."
     ],
     ],
     "This document will expire in %d days.": [
     "This document will expire in %d days.": [
-        "This document will expire in %d day. (singular)",
-        "This document will expire in %d days. (1st plural)",
-        "This document will expire in %d days. (2nd plural)",
-        "This document will expire in %d days. (3rd plural)",
-        "This document will expire in %d days. (4th plural)",
-        "This document will expire in %d days. (5th plural)"
+        "이 문서는 %d일 후에 만료됩니다.",
+        "이 문서는 %d일 후에 만료됩니다.",
+        "이 문서는 %d일 후에 만료됩니다.",
+        "이 문서는 %d일 후에 만료됩니다.",
+        "이 문서는 %d일 후에 만료됩니다.",
+        "이 문서는 %d일 후에 만료됩니다."
     ],
     ],
     "This document will expire in %d months.": [
     "This document will expire in %d months.": [
-        "This document will expire in %d month. (singular)",
-        "This document will expire in %d months. (1st plural)",
-        "This document will expire in %d months. (2nd plural)",
-        "This document will expire in %d months. (3rd plural)",
-        "This document will expire in %d months. (4th plural)",
-        "This document will expire in %d months. (5th plural)"
+        "이 문서는 %d개월 후에 만료됩니다.",
+        "이 문서는 %d개월 후에 만료됩니다.",
+        "이 문서는 %d개월 후에 만료됩니다.",
+        "이 문서는 %d개월 후에 만료됩니다.",
+        "이 문서는 %d개월 후에 만료됩니다.",
+        "이 문서는 %d개월 후에 만료됩니다."
     ],
     ],
-    "Please enter the password for this document:": "Please enter the password for this document:",
-    "Could not decrypt data (Wrong key?)": "Could not decrypt data (Wrong key?)",
-    "Could not delete the document, it was not stored in burn after reading mode.": "Could not delete the document, it was not stored in burn after reading mode.",
-    "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.",
-    "Could not decrypt comment; Wrong key?": "Could not decrypt comment; Wrong key?",
-    "Reply": "Reply",
-    "Anonymous": "Anonymous",
-    "Avatar generated from IP address": "Avatar generated from IP address",
-    "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",
-    "unknown status": "unknown status",
-    "server error or not responding": "server error or not responding",
-    "Could not post comment: %s": "Could not post comment: %s",
-    "Sending document…": "Sending document…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>",
-    "Delete data": "Delete data",
-    "Could not create document: %s": "Could not create document: %s",
-    "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
+    "Please enter the password for this document:": "이 문서의 비밀번호를 입력해 주세요:",
+    "Could not decrypt data (Wrong key?)": "데이터를 복호화할 수 없음 (잘못된 키인지 확인해 주세요.)",
+    "Could not delete the document, it was not stored in burn after reading mode.": "문서를 삭제할 수 없습니다. '읽은 후 삭제' 모드로 저장되지 않은 문서입니다.",
+    "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.": "본인만 확인 가능한 메시지입니다. 이 창을 닫지 마세요. 이 메시지는 다시 표시되지 않습니다.",
+    "Could not decrypt comment; Wrong key?": "댓글을 복호화할 수 없습니다. 잘못된 키인지 확인해 주세요.",
+    "Reply": "답글",
+    "Anonymous": "익명",
+    "Avatar generated from IP address": "IP 주소로 생성된 아바타",
+    "Add comment": "댓글 추가",
+    "Optional nickname…": "닉네임 (선택 사항)…",
+    "Post comment": "댓글 등록",
+    "Sending comment…": "댓글 전송 중…",
+    "Comment posted.": "댓글이 등록되었습니다.",
+    "Could not refresh display: %s": "화면을 새로고침할 수 없음: %s",
+    "unknown status": "알 수 없는 상태",
+    "server error or not responding": "서버 오류가 발생했거나 응답 없음",
+    "Could not post comment: %s": "댓글을 등록할 수 없음: %s",
+    "Sending document…": "문서 전송 중…",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "문서 주소: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(복사하려면 <kbd>%s</kbd>+<kbd>c</kbd> 키를 누르세요)</span>",
+    "Delete data": "데이터 삭제",
+    "Could not create document: %s": "문서를 생성할 수 없음: %s",
+    "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "문서를 복호화할 수 없음: URL에 복호화 키가 없습니다 (URL의 일부를 누락시키는 리다이렉터나 URL 단축 서비스를 사용하셨나요?)",
     "B": "B",
     "B": "B",
     "kB": "kB",
     "kB": "kB",
     "MB": "MB",
     "MB": "MB",
@@ -164,73 +164,75 @@
     "EB": "EB",
     "EB": "EB",
     "ZB": "ZB",
     "ZB": "ZB",
     "YB": "YB",
     "YB": "YB",
-    "Format": "Format",
-    "Plain Text": "Plain Text",
-    "Source Code": "Source Code",
-    "Markdown": "Markdown",
-    "Download attachment": "Download attachment",
-    "Cloned: '%s'": "Cloned: '%s'",
-    "The cloned file '%s' was attached to this document.": "The cloned file '%s' was attached to this document.",
-    "Attach a file": "Attach a file",
-    "alternatively drag & drop a file or paste an image from the clipboard": "alternatively drag & drop a file or paste an image from the clipboard",
-    "File too large, to display a preview. Please download the attachment.": "File too large, to display a preview. Please download the attachment.",
-    "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.",
-    "Invalid attachment.": "Invalid attachment.",
-    "Options": "Options",
-    "Shorten URL": "Shorten URL",
-    "Editor": "Editor",
-    "Preview": "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.",
-    "Decrypt": "Decrypt",
-    "Enter password": "Enter password",
-    "Loading…": "Loading…",
-    "Decrypting document…": "Decrypting document…",
-    "Preparing new document…": "Preparing new document…",
-    "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.",
-    "+++ no document text +++": "+++ no document text +++",
-    "Could not get document data: %s": "Could not get document data: %s",
-    "QR code": "QR code",
-    "This website is using an insecure HTTP connection! Please use it only for testing.": "This website is using an insecure HTTP connection! Please use it only for testing.",
-    "For more information <a href=\"%s\">see this FAQ entry</a>.": "For more information <a href=\"%s\">see this FAQ entry</a>.",
-    "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.": "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.",
-    "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.": "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.",
-    "waiting on user to provide a password": "waiting on user to provide a password",
-    "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.": "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.",
-    "Retry": "Retry",
-    "Showing raw text…": "Showing raw text…",
-    "Notice:": "Notice:",
-    "This link will expire after %s.": "This link will expire after %s.",
-    "This link can only be accessed once, do not use back or refresh button in your browser.": "This link can only be accessed once, do not use back or refresh button in your browser.",
-    "Link:": "Link:",
-    "Recipient may become aware of your timezone, convert time to UTC?": "Recipient may become aware of your timezone, convert time to UTC?",
-    "Use Current Timezone": "Use Current Timezone",
-    "Convert To UTC": "Convert To UTC",
-    "Close": "Close",
-    "Encrypted note on %s": "Encrypted note on %s",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
-    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
-    "URL shortener is enabled by default.": "URL shortener is enabled by default.",
-    "Save document": "Save document",
-    "Your IP is not authorized to create documents.": "Your IP is not authorized to create documents.",
-    "Trying to shorten a URL that isn't pointing at our instance.": "Trying to shorten a URL that isn't pointing at our instance.",
-    "Proxy error: Proxy URL is empty. This can be a configuration issue, like wrong or missing config keys.": "Proxy error: Proxy URL is empty. This can be a configuration issue, like wrong or missing config keys.",
-    "Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.": "Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.",
-    "Proxy error: Bad response. This can be a configuration issue, like wrong or missing config keys or a temporary outage.": "Proxy error: Bad response. This can be a configuration issue, like wrong or missing config keys or a temporary outage.",
-    "This secret message can only be displayed once. Would you like to see it now?": "This secret message can only be displayed once. Would you like to see it now?",
-    "Yes, see it": "Yes, see it",
-    "Dark Mode": "Dark Mode",
-    "Error compressing document, due to missing WebAssembly support.": "Error compressing document, due to missing WebAssembly support.",
-    "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
-    "Start over": "Start over",
-    "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
-    "Copy link": "Copy link",
-    "Link copied to clipboard": "Link copied to clipboard",
-    "Document text": "Document text",
-    "Tabulator key serves as character (Hit <kbd>Ctrl</kbd>+<kbd>m</kbd> or <kbd>Esc</kbd> to toggle)": "Tabulator key serves as character (Hit <kbd>Ctrl</kbd>+<kbd>m</kbd> or <kbd>Esc</kbd> to toggle)",
-    "Show password": "Show password",
-    "Hide password": "Hide password",
-    "Theme": "Theme",
-    "Copy": "Copy"
+    "Format": "형식",
+    "Plain Text": "일반 텍스트",
+    "Source Code": "소스 코드",
+    "Markdown": "마크다운",
+    "Download attachment": "첨부 파일 다운로드",
+    "Cloned: '%s'": "복제됨: '%s'",
+    "The cloned file '%s' was attached to this document.": "복제된 파일 '%s'이(가) 이 문서에 첨부되었습니다.",
+    "Attach a file": "파일 첨부",
+    "alternatively drag & drop a file or paste an image from the clipboard": "또는 파일을 끌어다 놓거나 클립보드에서 이미지를 붙여넣으세요",
+    "File too large, to display a preview. Please download the attachment.": "파일이 너무 커서 미리 보기를 표시할 수 없습니다. 첨부 파일을 다운로드해 주세요.",
+    "Remove attachment": "첨부 파일 삭제",
+    "Your browser does not support uploading encrypted files. Please use a newer browser.": "브라우저가 암호화된 파일 업로드를 지원하지 않습니다. 최신 브라우저를 사용해 주세요.",
+    "Invalid attachment.": "유효하지 않은 첨부 파일입니다.",
+    "Options": "옵션",
+    "Shorten URL": "URL 단축",
+    "Editor": "편집기",
+    "Preview": "미리 보기",
+    "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": "%s을(를) 실행하려면 PATH가 \"%s\"(으)로 끝나야 합니다. index.php 파일의 PATH 설정을 업데이트해 주세요.",
+    "Decrypt": "복호화",
+    "Enter password": "비밀번호 입력",
+    "Loading…": "로딩 중…",
+    "Decrypting document…": "문서 복호화 중…",
+    "Preparing new document…": "새 문서 준비 중…",
+    "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "이 메시지가 사라지지 않는 경우, 문제 해결을 위해 <a href=\"%s\">이 FAQ 항목</a>을 확인해 주세요.",
+    "+++ no document text +++": "+++ 문서 내용 없음 +++",
+    "Could not get document data: %s": "문서 데이터를 가져올 수 없음: %s",
+    "QR code": "QR 코드",
+    "This website is using an insecure HTTP connection! Please use it only for testing.": "이 웹사이트는 안전하지 않은 HTTP 연결을 사용하고 있습니다! 테스트 용도로만 사용해 주세요.",
+    "For more information <a href=\"%s\">see this FAQ entry</a>.": "자세한 정보는 <a href=\"%s\">이 FAQ 항목</a>을 참조하세요.",
+    "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.": "WebCrypto API를 지원하려면 브라우저에서 HTTPS 연결이 필요할 수 있습니다. <a href=\"%s\">HTTPS로 전환</a>해 보세요.",
+    "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.": "브라우저가 zlib 압축에 사용되는 WebAssembly를 지원하지 않습니다. 압축되지 않은 문서는 생성할 수 있으나, 압축된 문서는 읽을 수 없습니다.",
+    "waiting on user to provide a password": "사용자가 비밀번호를 입력하기를 기다리는 중",
+    "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.": "데이터를 복호화할 수 없습니다. 비밀번호를 잘못 입력하셨나요? 상단의 버튼을 눌러 다시 시도해 주세요.",
+    "Retry": "다시 시도",
+    "Showing raw text…": "원본 텍스트 표시 중…",
+    "Notice:": "알림:",
+    "This link will expire after %s.": "이 링크는 %s 후에 만료됩니다.",
+    "This link can only be accessed once, do not use back or refresh button in your browser.": "이 링크는 한 번만 접속할 수 있으므로, 브라우저의 뒤로 가기나 새로고침 버튼을 사용하지 마세요.",
+    "Link:": "링크:",
+    "Recipient may become aware of your timezone, convert time to UTC?": "수신자가 귀하의 시간대를 알게 될 수 있습니다. 시간을 UTC로 변환하시겠습니까?",
+    "Use Current Timezone": "현재 시간대 사용",
+    "Convert To UTC": "UTC로 변환",
+    "Close": "닫기",
+    "Encrypted note on %s": "%s에 작성된 암호화된 메모",
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "메모를 확인하려면 이 링크를 방문하세요. 다른 사람에게 URL을 공유하면 그 사람도 메모에 접근할 수 있게 됩니다.",
+    "URL shortener may expose your decrypt key in URL.": "URL 단축 서비스를 사용하면 URL에 포함된 복호화 키가 노출될 수 있습니다.",
+    "URL shortener is enabled by default.": "URL 단축 기능이 기본으로 활성화되어 있습니다.",
+    "Save document": "문서 저장",
+    "Your IP is not authorized to create documents.": "귀하의 IP는 문서 생성 권한이 없습니다.",
+    "Trying to shorten a URL that isn't pointing at our instance.": "현재 인스턴스를 가리키지 않는 URL을 단축하려고 시도했습니다.",
+    "Proxy error: Proxy URL is empty. This can be a configuration issue, like wrong or missing config keys.": "프록시 오류: 프록시 URL이 비어 있습니다. 잘못된 설정 키이거나 누락된 설정 때문일 수 있습니다.",
+    "Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.": "프록시 오류: 프록시 응답을 구문 분석하는 중 오류가 발생했습니다. 잘못된 설정 키이거나 누락된 설정 때문일 수 있습니다.",
+    "Proxy error: Bad response. This can be a configuration issue, like wrong or missing config keys or a temporary outage.": "프록시 오류: 잘못된 응답입니다. 키 누락이나 잘못된 값 입력과 같은 설정 오류이거나 일시적인 서버 장애일 수 있습니다.",
+    "This secret message can only be displayed once. Would you like to see it now?": "이 비밀 메시지는 단 한 번만 표시됩니다. 지금 확인하시겠습니까?",
+    "Yes, see it": "네, 확인하겠습니다",
+    "Dark Mode": "다크 모드",
+    "Error compressing document, due to missing WebAssembly support.": "WebAssembly 지원이 누락되어 문서를 압축하는 동안 오류가 발생했습니다.",
+    "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "문서 압축 해제 오류: 브라우저가 WebAssembly를 지원하지 않습니다. 이 문서를 보려면 다른 브라우저를 사용해 주세요.",
+    "Start over": "처음부터 다시 시작",
+    "Document copied to clipboard": "문서가 클립보드에 복사됨",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "문서를 복사하려면 복사 버튼을 누르거나 클립보드 단축키 <kbd>%s</kbd>+<kbd>c</kbd>를 사용하세요",
+    "Copy link": "링크 복사",
+    "Link copied to clipboard": "링크가 클립보드에 복사됨",
+    "Document text": "문서 텍스트",
+    "Tabulator key serves as character (Hit <kbd>Ctrl</kbd>+<kbd>m</kbd> or <kbd>Esc</kbd> to toggle)": "탭 키가 문자로 입력됩니다 (전환하려면 <kbd>Ctrl</kbd>+<kbd>m</kbd> 또는 <kbd>Esc</kbd> 키를 누르세요)",
+    "Show password": "비밀번호 표시",
+    "Hide password": "비밀번호 숨기기",
+    "Theme": "테마",
+    "Copy": "복사",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/ku.json

@@ -151,7 +151,7 @@
     "server error or not responding": "server error or not responding",
     "server error or not responding": "server error or not responding",
     "Could not post comment: %s": "Could not post comment: %s",
     "Could not post comment: %s": "Could not post comment: %s",
     "Sending document…": "Sending document…",
     "Sending document…": "Sending document…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>",
     "Delete data": "Delete data",
     "Delete data": "Delete data",
     "Could not create document: %s": "Could not create document: %s",
     "Could not create document: %s": "Could not create document: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/la.json

@@ -151,7 +151,7 @@
     "server error or not responding": "server error or not responding",
     "server error or not responding": "server error or not responding",
     "Could not post comment: %s": "Could not post comment: %s",
     "Could not post comment: %s": "Could not post comment: %s",
     "Sending document…": "Sending document…",
     "Sending document…": "Sending document…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>",
     "Delete data": "Delete data",
     "Delete data": "Delete data",
     "Could not create document: %s": "Could not create document: %s",
     "Could not create document: %s": "Could not create document: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 1 - 0
i18n/languages.json

@@ -30,6 +30,7 @@
     "ce": ["нохчийн мотт", "Chechen"],
     "ce": ["нохчийн мотт", "Chechen"],
     "ny": ["chiCheŵa", "Chichewa"],
     "ny": ["chiCheŵa", "Chichewa"],
     "zh": ["中文", "Chinese"],
     "zh": ["中文", "Chinese"],
+    "zh-tw": ["繁體中文", "Chinese (Traditional)"],
     "cv": ["чӑваш чӗлхи", "Chuvash"],
     "cv": ["чӑваш чӗлхи", "Chuvash"],
     "kw": ["Kernewek", "Cornish"],
     "kw": ["Kernewek", "Cornish"],
     "co": ["corsu", "Corsican"],
     "co": ["corsu", "Corsican"],

+ 5 - 3
i18n/lt.json

@@ -151,7 +151,7 @@
     "server error or not responding": "serverio klaida arba jis neatsako",
     "server error or not responding": "serverio klaida arba jis neatsako",
     "Could not post comment: %s": "Nepavyko paskelbti komentaro: %s",
     "Could not post comment: %s": "Nepavyko paskelbti komentaro: %s",
     "Sending document…": "Siunčiamas dokumentas…",
     "Sending document…": "Siunčiamas dokumentas…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Jūsų dokumentas yra <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Paspauskite <kbd>Ctrl</kbd>+<kbd>c</kbd> norėdami nukopijuoti)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Jūsų dokumentas yra <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Paspauskite <kbd>%s</kbd>+<kbd>c</kbd> norėdami nukopijuoti)</span>",
     "Delete data": "Ištrinti duomenis",
     "Delete data": "Ištrinti duomenis",
     "Could not create document: %s": "Nepavyko sukurti dokumento: %s",
     "Could not create document: %s": "Nepavyko sukurti dokumento: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nepavyksta iššifruoti dokumento: URL adrese trūksta iššifravimo rakto (Ar naudojote peradresavimo ar URL trumpinimo įrankį, kuris pašalina URL dalį?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nepavyksta iššifruoti dokumento: URL adrese trūksta iššifravimo rakto (Ar naudojote peradresavimo ar URL trumpinimo įrankį, kuris pašalina URL dalį?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Klaida išglaudinant dokumentą, jūsų naršyklė nepalaiko „WebAssembly“. Norėdami peržiūrėti šį dokumentą, naudokite kitą naršyklę.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Klaida išglaudinant dokumentą, jūsų naršyklė nepalaiko „WebAssembly“. Norėdami peržiūrėti šį dokumentą, naudokite kitą naršyklę.",
     "Start over": "Pradėti iš naujo",
     "Start over": "Pradėti iš naujo",
     "Document copied to clipboard": "Dokumentas nukopijuotas į iškarpinę",
     "Document copied to clipboard": "Dokumentas nukopijuotas į iškarpinę",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Norėdami nukopijuoti dokumentą paspauskite kopijavimo mygtuką arba naudokite iškarpinės sparčiuosius klavišus <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Norėdami nukopijuoti dokumentą paspauskite kopijavimo mygtuką arba naudokite iškarpinės sparčiuosius klavišus <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Kopijuoti nuorodą",
     "Copy link": "Kopijuoti nuorodą",
     "Link copied to clipboard": "Nuoroda nukopijuota į iškarpinę",
     "Link copied to clipboard": "Nuoroda nukopijuota į iškarpinę",
     "Document text": "Dokumento tekstas",
     "Document text": "Dokumento tekstas",
@@ -232,5 +232,7 @@
     "Show password": "Rodyti slaptažodį",
     "Show password": "Rodyti slaptažodį",
     "Hide password": "Slėpti slaptažodį",
     "Hide password": "Slėpti slaptažodį",
     "Theme": "Apipavidalinimas",
     "Theme": "Apipavidalinimas",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/nl.json

@@ -151,7 +151,7 @@
     "server error or not responding": "Serverfout of server reageert niet",
     "server error or not responding": "Serverfout of server reageert niet",
     "Could not post comment: %s": "Kon het commentaar niet plaatsen: %s",
     "Could not post comment: %s": "Kon het commentaar niet plaatsen: %s",
     "Sending document…": "Document verzenden…",
     "Sending document…": "Document verzenden…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Je document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Druk <kbd>Ctrl</kbd>+<kbd>c</kbd> om te kopiëren)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Je document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Druk <kbd>%s</kbd>+<kbd>c</kbd> om te kopiëren)</span>",
     "Delete data": "Gegevens wissen",
     "Delete data": "Gegevens wissen",
     "Could not create document: %s": "Kon het document niet aanmaken: %s",
     "Could not create document: %s": "Kon het document niet aanmaken: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Kon het document niet decoderen: Decoderingssleutel ontbreekt in URL (Heb je een doorverwijzer of een URL-verkorter gebruikt die een deel van de URL verwijdert?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Kon het document niet decoderen: Decoderingssleutel ontbreekt in URL (Heb je een doorverwijzer of een URL-verkorter gebruikt die een deel van de URL verwijdert?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Fout bij het decomprimeren van het document, uw browser ondersteunt WebAssembly niet. Gebruik een andere browser om dit document te bekijken.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Fout bij het decomprimeren van het document, uw browser ondersteunt WebAssembly niet. Gebruik een andere browser om dit document te bekijken.",
     "Start over": "Opnieuw beginnen",
     "Start over": "Opnieuw beginnen",
     "Document copied to clipboard": "Document gekopieerd naar klembord",
     "Document copied to clipboard": "Document gekopieerd naar klembord",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Om te kopiëren en plakken druk je op de knop Kopiëren of gebruik je de sneltoets op het klembord <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Om te kopiëren en plakken druk je op de knop Kopiëren of gebruik je de sneltoets op het klembord <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Kopieer link",
     "Copy link": "Kopieer link",
     "Link copied to clipboard": "Link gekopieerd naar klembord",
     "Link copied to clipboard": "Link gekopieerd naar klembord",
     "Document text": "Tekst plakken",
     "Document text": "Tekst plakken",
@@ -232,5 +232,7 @@
     "Show password": "Toon wachtwoord",
     "Show password": "Toon wachtwoord",
     "Hide password": "Verberg wachtwoord",
     "Hide password": "Verberg wachtwoord",
     "Theme": "Thema",
     "Theme": "Thema",
-    "Copy": "Kopieëren"
+    "Copy": "Kopieëren",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/no.json

@@ -151,7 +151,7 @@
     "server error or not responding": "tjener feilet eller svarer ikke",
     "server error or not responding": "tjener feilet eller svarer ikke",
     "Could not post comment: %s": "Kunne ikke sende kommentar: %s",
     "Could not post comment: %s": "Kunne ikke sende kommentar: %s",
     "Sending document…": "Sender innlegg…",
     "Sending document…": "Sender innlegg…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Ditt innlegg er <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Trykk <kbd>Ctrl</kbd>+<kbd>c</kbd> for å kopiere)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Ditt innlegg er <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Trykk <kbd>%s</kbd>+<kbd>c</kbd> for å kopiere)</span>",
     "Delete data": "Slett data",
     "Delete data": "Slett data",
     "Could not create document: %s": "Kunne ikke opprette innlegg: %s",
     "Could not create document: %s": "Kunne ikke opprette innlegg: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Kan ikke dekryptere innlegg: Dekrypteringsnøkkelen mangler i adressen (Har du bruket en redirector eller en URL forkorter som fjerner en del av addressen?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Kan ikke dekryptere innlegg: Dekrypteringsnøkkelen mangler i adressen (Har du bruket en redirector eller en URL forkorter som fjerner en del av addressen?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/oc.json

@@ -151,7 +151,7 @@
     "server error or not responding": "lo servidor respond pas o a rescontrat una error",
     "server error or not responding": "lo servidor respond pas o a rescontrat una error",
     "Could not post comment: %s": "Impossible de mandar lo comentari : %s",
     "Could not post comment: %s": "Impossible de mandar lo comentari : %s",
     "Sending document…": "Mandadís del tèxte…",
     "Sending document…": "Mandadís del tèxte…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> 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 <kbd>Ctrl</kbd>+<kbd>c</kbd> per copiar)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> 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 <kbd>%s</kbd>+<kbd>c</kbd> per copiar)</span>",
     "Delete data": "Supprimir las donadas del tèxte",
     "Delete data": "Supprimir las donadas del tèxte",
     "Could not create document: %s": "Impossible de crear lo tèxte : %s",
     "Could not create document: %s": "Impossible de crear lo tèxte : %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Impossible de deschifrar lo tèxte : clau de deschiframent absenta de l’URL (Avètz utilizat un redirector o un site de reduccion d’URL que suprimís una partida de l’URL ?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Impossible de deschifrar lo tèxte : clau de deschiframent absenta de l’URL (Avètz utilizat un redirector o un site de reduccion d’URL que suprimís una partida de l’URL ?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error al moment de descompresar l'empegatge, vòstre navegador pren pas en carga WebAssembly. Mercés d'utilizar un autre navigador per visualizar aquesta pega.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error al moment de descompresar l'empegatge, vòstre navegador pren pas en carga WebAssembly. Mercés d'utilizar un autre navigador per visualizar aquesta pega.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/pl.json

@@ -151,7 +151,7 @@
     "server error or not responding": "błąd serwera lub brak odpowiedzi",
     "server error or not responding": "błąd serwera lub brak odpowiedzi",
     "Could not post comment: %s": "Nie udało się wysłać komentarza: %s",
     "Could not post comment: %s": "Nie udało się wysłać komentarza: %s",
     "Sending document…": "Wysyłanie dokumentu…",
     "Sending document…": "Wysyłanie dokumentu…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Twój dokument: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Użyj <kbd>Ctrl</kbd>+<kbd>c</kbd>, aby skopiować)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Twój dokument: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Użyj <kbd>%s</kbd>+<kbd>c</kbd>, aby skopiować)</span>",
     "Delete data": "Usuń dane",
     "Delete data": "Usuń dane",
     "Could not create document: %s": "Nie można utworzyć dokumentu: %s",
     "Could not create document: %s": "Nie można utworzyć dokumentu: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nie można odszyfrować dokumentu — brakuje klucza deszyfrującego w adresie URL (czy użyto przekierowania lub skracacza adresów, który usuwa część linku?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nie można odszyfrować dokumentu — brakuje klucza deszyfrującego w adresie URL (czy użyto przekierowania lub skracacza adresów, który usuwa część linku?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Błąd dekompresji dokumentu — Twoja przeglądarka nie obsługuje WebAssembly. Użyj innej przeglądarki, aby go otworzyć.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Błąd dekompresji dokumentu — Twoja przeglądarka nie obsługuje WebAssembly. Użyj innej przeglądarki, aby go otworzyć.",
     "Start over": "Zacznij od nowa",
     "Start over": "Zacznij od nowa",
     "Document copied to clipboard": "Dokument skopiowany do schowka",
     "Document copied to clipboard": "Dokument skopiowany do schowka",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Aby skopiować dokument, kliknij przycisk kopiowania lub użyj skrótu <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Aby skopiować dokument, kliknij przycisk kopiowania lub użyj skrótu <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Kopiuj link",
     "Copy link": "Kopiuj link",
     "Link copied to clipboard": "Link skopiowany do schowka",
     "Link copied to clipboard": "Link skopiowany do schowka",
     "Document text": "Treść dokumentu",
     "Document text": "Treść dokumentu",
@@ -232,5 +232,7 @@
     "Show password": "Pokaż hasło",
     "Show password": "Pokaż hasło",
     "Hide password": "Ukryj hasło",
     "Hide password": "Ukryj hasło",
     "Theme": "Motyw",
     "Theme": "Motyw",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/pt.json

@@ -151,7 +151,7 @@
     "server error or not responding": "Servidor em erro ou não responsivo",
     "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",
     "Could not post comment: %s": "Não foi possível publicar o comentário: %s",
     "Sending document…": "Enviando cópia…",
     "Sending document…": "Enviando cópia…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Sua cópia é <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Pressione <kbd>Ctrl</kbd>+<kbd>c</kbd> para copiar)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Sua cópia é <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Pressione <kbd>%s</kbd>+<kbd>c</kbd> para copiar)</span>",
     "Delete data": "Excluir dados",
     "Delete data": "Excluir dados",
     "Could not create document: %s": "Não foi possível criar cópia: %s",
     "Could not create document: %s": "Não foi possível criar cópia: %s",
     "Cannot decrypt document: 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?)",
     "Cannot decrypt document: 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?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/ro.json

@@ -151,7 +151,7 @@
     "server error or not responding": "eroare de server sau nu răspunde",
     "server error or not responding": "eroare de server sau nu răspunde",
     "Could not post comment: %s": "Nu s-a putut posta comentariul: %s",
     "Could not post comment: %s": "Nu s-a putut posta comentariul: %s",
     "Sending document…": "Se trimite document-ul…",
     "Sending document…": "Se trimite document-ul…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Document-ul dvs. este <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Apăsați <kbd>Ctrl</kbd>+<kbd>c</kbd> pentru a copia)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Document-ul dvs. este <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Apăsați <kbd>%s</kbd>+<kbd>c</kbd> pentru a copia)</span>",
     "Delete data": "Ștergeți datele",
     "Delete data": "Ștergeți datele",
     "Could not create document: %s": "Nu s-a putut crea document-ul: %s",
     "Could not create document: %s": "Nu s-a putut crea document-ul: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nu s-a putut decripta document-ul: Cheia de decriptare lipsește din URL (Ați folosit un redirector sau un scurtător de URL care a tăiat o parte din URL?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nu s-a putut decripta document-ul: Cheia de decriptare lipsește din URL (Ați folosit un redirector sau un scurtător de URL care a tăiat o parte din URL?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Eroare la deschiderea document-ului, browserul dvs. nu acceptă WebAssembly. Vă rugăm să utilizați un alt browser pentru a vedea acest document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Eroare la deschiderea document-ului, browserul dvs. nu acceptă WebAssembly. Vă rugăm să utilizați un alt browser pentru a vedea acest document.",
     "Start over": "Start over",
     "Start over": "Start over",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/ru.json

@@ -151,7 +151,7 @@
     "server error or not responding": "ошибка сервера или нет ответа",
     "server error or not responding": "ошибка сервера или нет ответа",
     "Could not post comment: %s": "Не удалось опубликовать комментарий: %s",
     "Could not post comment: %s": "Не удалось опубликовать комментарий: %s",
     "Sending document…": "Отправка записи…",
     "Sending document…": "Отправка записи…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Ссылка на запись <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Нажмите <kbd>Ctrl</kbd>+<kbd>c</kbd>, чтобы скопировать ссылку)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Ссылка на запись <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Нажмите <kbd>%s</kbd>+<kbd>c</kbd>, чтобы скопировать ссылку)</span>",
     "Delete data": "Удалить запись",
     "Delete data": "Удалить запись",
     "Could not create document: %s": "Не удалось опубликовать запись: %s",
     "Could not create document: %s": "Не удалось опубликовать запись: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Невозможно расшифровать запись: Ключ расшифровки отсутствует в ссылке (Может быть, вы используете сокращатель ссылок, который удаляет часть ссылки?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Невозможно расшифровать запись: Ключ расшифровки отсутствует в ссылке (Может быть, вы используете сокращатель ссылок, который удаляет часть ссылки?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Ошибка распаковки документа, ваш браузер не поддерживает WebAssembly. Пожалуйста, используйте другой браузер для просмотра этого документа.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Ошибка распаковки документа, ваш браузер не поддерживает WebAssembly. Пожалуйста, используйте другой браузер для просмотра этого документа.",
     "Start over": "Начать заново",
     "Start over": "Начать заново",
     "Document copied to clipboard": "Запись скопирована в буфер обмена",
     "Document copied to clipboard": "Запись скопирована в буфер обмена",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Чтобы скопировать запись нажмите на кнопку копирования или используйте комбинацию клавиш <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Чтобы скопировать запись нажмите на кнопку копирования или используйте комбинацию клавиш <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Скопировать ссылку",
     "Copy link": "Скопировать ссылку",
     "Link copied to clipboard": "Ссылка скопирована в буфер обмена",
     "Link copied to clipboard": "Ссылка скопирована в буфер обмена",
     "Document text": "Текст документа",
     "Document text": "Текст документа",
@@ -232,5 +232,7 @@
     "Show password": "Показать пароль",
     "Show password": "Показать пароль",
     "Hide password": "Скрыть пароль",
     "Hide password": "Скрыть пароль",
     "Theme": "Тема",
     "Theme": "Тема",
-    "Copy": "Скопировать"
+    "Copy": "Скопировать",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/sk.json

@@ -151,7 +151,7 @@
     "server error or not responding": "chyba servera alebo server neodpovedá",
     "server error or not responding": "chyba servera alebo server neodpovedá",
     "Could not post comment: %s": "Nepodarilo sa pridať komentár: %s",
     "Could not post comment: %s": "Nepodarilo sa pridať komentár: %s",
     "Sending document…": "Odosiela sa príspevok…",
     "Sending document…": "Odosiela sa príspevok…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Váš príspevok je <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(skopírujte stlačením <kbd>Ctrl</kbd>+<kbd>c</kbd>)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Váš príspevok je <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(skopírujte stlačením <kbd>%s</kbd>+<kbd>c</kbd>)</span>",
     "Delete data": "Odstrániť dáta",
     "Delete data": "Odstrániť dáta",
     "Could not create document: %s": "Nepodarilo sa vytvoriť príspevok: %s",
     "Could not create document: %s": "Nepodarilo sa vytvoriť príspevok: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nie je možné dešifrovať príspevok: V URL adrese chýba dešifrovací kľúč (Použili ste presmerovač alebo skracovač adresy, ktorý odstráni časť adresy URL?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nie je možné dešifrovať príspevok: V URL adrese chýba dešifrovací kľúč (Použili ste presmerovač alebo skracovač adresy, ktorý odstráni časť adresy URL?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.",
     "Start over": "Začať odznova",
     "Start over": "Začať odznova",
     "Document copied to clipboard": "Kópia vložená do schránky",
     "Document copied to clipboard": "Kópia vložená do schránky",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Kopírovať odkaz",
     "Copy link": "Kopírovať odkaz",
     "Link copied to clipboard": "Odkaz vložený do schránky",
     "Link copied to clipboard": "Odkaz vložený do schránky",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 8 - 6
i18n/sl.json

@@ -105,7 +105,7 @@
         "Ta dokument bo potekel čez %d minuto.",
         "Ta dokument bo potekel čez %d minuto.",
         "Ta dokument bo potekel čez %d minuti.",
         "Ta dokument bo potekel čez %d minuti.",
         "Ta dokument bo potekel čez %d minute.",
         "Ta dokument bo potekel čez %d minute.",
-        "Ta dokument bo potekel čez %d minut.",
+        "Ta dokument bo potekel čez %d minute.",
         "Ta dokument bo potekel čez %d minut.",
         "Ta dokument bo potekel čez %d minut.",
         "Ta dokument bo potekel čez %d minut."
         "Ta dokument bo potekel čez %d minut."
     ],
     ],
@@ -113,13 +113,13 @@
         "Ta dokument bo potekel čez %d uro.",
         "Ta dokument bo potekel čez %d uro.",
         "Ta dokument bo potekel čez %d uri.",
         "Ta dokument bo potekel čez %d uri.",
         "Ta dokument bo potekel čez %d ure.",
         "Ta dokument bo potekel čez %d ure.",
-        "Ta dokument bo potekel čez %d ur.",
+        "Ta dokument bo potekel čez %d ure.",
         "Ta dokument bo potekel čez %d ur.",
         "Ta dokument bo potekel čez %d ur.",
         "Ta dokument bo potekel čez %d ur."
         "Ta dokument bo potekel čez %d ur."
     ],
     ],
     "This document will expire in %d days.": [
     "This document will expire in %d days.": [
         "Ta dokument bo potekel čez %d dan.",
         "Ta dokument bo potekel čez %d dan.",
-        "Ta dokument bo potekel čez %d dni.",
+        "Ta dokument bo potekel čez %d dneva.",
         "Ta dokument bo potekel čez %d dni.",
         "Ta dokument bo potekel čez %d dni.",
         "Ta dokument bo potekel čez %d dni.",
         "Ta dokument bo potekel čez %d dni.",
         "Ta dokument bo potekel čez %d dni.",
         "Ta dokument bo potekel čez %d dni.",
@@ -151,7 +151,7 @@
     "server error or not responding": "napaka na strežniku, ali pa se strežnik ne odziva",
     "server error or not responding": "napaka na strežniku, ali pa se strežnik ne odziva",
     "Could not post comment: %s": "Komentarja ni bilo mogoče objaviti : %s",
     "Could not post comment: %s": "Komentarja ni bilo mogoče objaviti : %s",
     "Sending document…": "Pošiljam prilepek…",
     "Sending document…": "Pošiljam prilepek…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Tvoj prilepek je dostopen na naslovu: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Pritisni <kbd>Ctrl</kbd>+<kbd>c</kbd> ali [Cmd] + [c] in skopiraj)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Tvoj prilepek je dostopen na naslovu: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Pritisni <kbd>%s</kbd>+<kbd>c</kbd> ali [Cmd] + [c] in skopiraj)</span>",
     "Delete data": "Izbriši podatke",
     "Delete data": "Izbriši podatke",
     "Could not create document: %s": "Ne morem ustvariti prilepka: %s",
     "Could not create document: %s": "Ne morem ustvariti prilepka: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Prilepka ni mogoče odkodirati: v URL-ju manjka ključ. Ali je bil uporabljen krajšalnik URL-jev, ki odstrani del URL-ja?",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Prilepka ni mogoče odkodirati: v URL-ju manjka ključ. Ali je bil uporabljen krajšalnik URL-jev, ki odstrani del URL-ja?",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Napaka pri razpakiranju dokumenta, vaš brskalnik ne podpira WebAssembly. Za ogled tega dokumenta uporabite drug brskalnik.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Napaka pri razpakiranju dokumenta, vaš brskalnik ne podpira WebAssembly. Za ogled tega dokumenta uporabite drug brskalnik.",
     "Start over": "Začni znova",
     "Start over": "Začni znova",
     "Document copied to clipboard": "Dokument kopiran v odložišče",
     "Document copied to clipboard": "Dokument kopiran v odložišče",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Za kopiranje dokumenta pritisnite gumb za kopiranje ali uporabite bližnjico odložišča <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Za kopiranje dokumenta pritisnite gumb za kopiranje ali uporabite bližnjico odložišča <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Kopiraj povezavo",
     "Copy link": "Kopiraj povezavo",
     "Link copied to clipboard": "Povezava kopirana v odložišče",
     "Link copied to clipboard": "Povezava kopirana v odložišče",
     "Document text": "Besedilo dokumenta",
     "Document text": "Besedilo dokumenta",
@@ -232,5 +232,7 @@
     "Show password": "Pokaži geslo",
     "Show password": "Pokaži geslo",
     "Hide password": "Skrij geslo",
     "Hide password": "Skrij geslo",
     "Theme": "Tema",
     "Theme": "Tema",
-    "Copy": "Copy"
+    "Copy": "Kopiraj",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/sv.json

@@ -151,7 +151,7 @@
     "server error or not responding": "serverfel eller inget svar",
     "server error or not responding": "serverfel eller inget svar",
     "Could not post comment: %s": "Kunde inte publicera kommentar: %s",
     "Could not post comment: %s": "Kunde inte publicera kommentar: %s",
     "Sending document…": "Skickar dokument…",
     "Sending document…": "Skickar dokument…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Ditt dokument är <a id=\\\"pasteurl\\\" href=\\\"%s\\\">%s</a> <span id=\\\"copyhint\\\">(Tryck <kbd>Ctrl</kbd>+<kbd>c</kbd> för att kopiera)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Ditt dokument är <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Tryck <kbd>%s</kbd>+<kbd>c</kbd> för att kopiera)</span>",
     "Delete data": "Radera data",
     "Delete data": "Radera data",
     "Could not create document: %s": "Kunde inte skapa dokument: %s",
     "Could not create document: %s": "Kunde inte skapa dokument: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Kan inte dekryptera dokument: Dekrypteringsnyckel saknas i URL:en (använde du en omdirigerare eller URL-förkortare som tar bort delar av URL:en?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Kan inte dekryptera dokument: Dekrypteringsnyckel saknas i URL:en (använde du en omdirigerare eller URL-förkortare som tar bort delar av URL:en?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Fel vid dekomprimering av dokumentet, din webbläsare stöder inte WebAssembly. Använd en annan webbläsare för att visa detta dokument.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Fel vid dekomprimering av dokumentet, din webbläsare stöder inte WebAssembly. Använd en annan webbläsare för att visa detta dokument.",
     "Start over": "Börja om",
     "Start over": "Börja om",
     "Document copied to clipboard": "Dokument kopierat till urklipp",
     "Document copied to clipboard": "Dokument kopierat till urklipp",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "För att kopiera dokumentet, klicka på kopieringsknappen eller använd kortkommandot <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "För att kopiera dokumentet, klicka på kopieringsknappen eller använd kortkommandot <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Kopiera länk",
     "Copy link": "Kopiera länk",
     "Link copied to clipboard": "Länk kopierad till urklipp",
     "Link copied to clipboard": "Länk kopierad till urklipp",
     "Document text": "Dokumenttext",
     "Document text": "Dokumenttext",
@@ -232,5 +232,7 @@
     "Show password": "Visa lösenord",
     "Show password": "Visa lösenord",
     "Hide password": "Dölj lösenord",
     "Hide password": "Dölj lösenord",
     "Theme": "Tema",
     "Theme": "Tema",
-    "Copy": "Copy"
+    "Copy": "Kopiera",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 45 - 43
i18n/th.json

@@ -1,9 +1,9 @@
 {
 {
     "PrivateBin": "PrivateBin",
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s เป็น pastebin ออนไลน์แบบโอเพ่นซอร์สที่มีรูปแบบการใช้งานที่เรียบง่าย เซิร์ฟเวอร์ไม่สามารถรู้ได้ว่าข้อมูลโค้ดที่มาฝากนั้นเป็นข้อมูลอะไร โดยจะถูกเข้ารหัส/ถอดรหัสด้วยกระบวนการ AES จำนวน 256 บิต%sผ่านเบราว์เซอร์%s",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s เป็น pastebin ออนไลน์แบบมินิมอลและโอเพนซอร์ส ที่เซิร์ฟเวอร์ไม่รู้ข้อมูลใดๆ เกี่ยวกับข้อมูลที่จัดเก็บไว้ ข้อมูลจะถูกเข้ารหัส/ถอดรหัส %ในเบราว์เซอร์%s โดยใช้ AES 256 บิต",
     "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "ข้อมูลเพิ่มเติม ดูได้ที่<a href=\"https://privatebin.info/\">หน้าโครงการ</a>",
     "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "ข้อมูลเพิ่มเติม ดูได้ที่<a href=\"https://privatebin.info/\">หน้าโครงการ</a>",
     "Because ignorance is bliss": "ไม่รู้ไม่ชี้ดีที่สุด",
     "Because ignorance is bliss": "ไม่รู้ไม่ชี้ดีที่สุด",
-    "Document does not exist, has expired or has been deleted.": "การฝากโค้ดไม่มีอยู่ อาจจะหมดอายุหรือถูกลบไปแล้ว",
+    "Document does not exist, has expired or has been deleted.": "เอกสารนี้ไม่มีอยู่จริง หมดอายุ หรือถูกลบไปแล้ว",
     "%s requires php %s or above to work. Sorry.": "ขออภัย %s ต้องใช้ PHP %s ขึ้นไปจึงจะใช้งานได้",
     "%s requires php %s or above to work. Sorry.": "ขออภัย %s ต้องใช้ PHP %s ขึ้นไปจึงจะใช้งานได้",
     "%s requires configuration section [%s] to be present in configuration file.": "%s จำเป็นต้องตั้งค่าตัวแปร [%s] ในไฟล์กำหนดค่า",
     "%s requires configuration section [%s] to be present in configuration file.": "%s จำเป็นต้องตั้งค่าตัวแปร [%s] ในไฟล์กำหนดค่า",
     "Please wait %d seconds between each post.": [
     "Please wait %d seconds between each post.": [
@@ -14,19 +14,19 @@
         "กรุณาเว้นระยะเวลาการส่งข้อมูลอย่างน้อย %d วินาที",
         "กรุณาเว้นระยะเวลาการส่งข้อมูลอย่างน้อย %d วินาที",
         "กรุณาเว้นระยะเวลาการส่งข้อมูลอย่างน้อย %d วินาที"
         "กรุณาเว้นระยะเวลาการส่งข้อมูลอย่างน้อย %d วินาที"
     ],
     ],
-    "Document is limited to %s of encrypted data.": "การฝากโค้ดแบบเข้ารหัส ขีดจำกัดสูงสุดคือ %s",
+    "Document is limited to %s of encrypted data.": "เอกสารนี้มีข้อจำกัดด้านพื้นที่จัดเก็บข้อมูลที่เข้ารหัสไว้ที่ %s",
     "Invalid data.": "ข้อมูลไม่ถูกต้อง",
     "Invalid data.": "ข้อมูลไม่ถูกต้อง",
     "You are unlucky. Try again.": "วันนี้คุณดวงไม่เฮงเลย ลองใหม่อีกครั้งนะ",
     "You are unlucky. Try again.": "วันนี้คุณดวงไม่เฮงเลย ลองใหม่อีกครั้งนะ",
     "Error saving comment. Sorry.": "ขออภัย เกิดข้อผิดพลาดในระหว่างบันทึกความคิดเห็น",
     "Error saving comment. Sorry.": "ขออภัย เกิดข้อผิดพลาดในระหว่างบันทึกความคิดเห็น",
-    "Error saving document. Sorry.": "ขออภัย เกิดข้อผิดพลาดในระหว่างบันทึกการฝากโค้ด",
-    "Invalid document ID.": "ID การฝากโค้ดไม่ถูกต้อง",
-    "Document is not of burn-after-reading type.": "ข้อมูลการฝากโค้ดนี้ไม่ได้เป็นรูปแบบลบทันทีเมื่อเปิดอ่าน",
-    "Wrong deletion token. Document was not deleted.": "โทเค็นการลบไม่ถูกต้อง ข้อมูลการฝากโค้ดไม่ถูกลบ",
-    "Document was properly deleted.": "ข้อมูลการฝากโค้ดถูกลบออกเรียบร้อยแล้ว",
+    "Error saving document. Sorry.": "ขออภัย เกิดข้อผิดพลาดในการบันทึกเอกสาร",
+    "Invalid document ID.": "รหัสเอกสารไม่ถูกต้อง",
+    "Document is not of burn-after-reading type.": "เอกสารนี้ไม่ใช่เอกสารประเภทที่ต้องลบทิ้งหลังจากอ่านแล้ว",
+    "Wrong deletion token. Document was not deleted.": "รหัสลบไม่ถูกต้อง เอกสารไม่ได้ถูกลบ",
+    "Document was properly deleted.": "เอกสารถูกลบแล้ว",
     "JavaScript is required for %s to work. Sorry for the inconvenience.": "จำเป็นต้องใช้ JavaScript เพื่อให้ %s สามารถทำงานได้ ขออภัยในความไม่สะดวก",
     "JavaScript is required for %s to work. Sorry for the inconvenience.": "จำเป็นต้องใช้ JavaScript เพื่อให้ %s สามารถทำงานได้ ขออภัยในความไม่สะดวก",
     "%s requires a modern browser to work.": "%s ต้องใช้เบราว์เซอร์สมัยใหม่ถึงจะสามารถใช้งานได้",
     "%s requires a modern browser to work.": "%s ต้องใช้เบราว์เซอร์สมัยใหม่ถึงจะสามารถใช้งานได้",
     "New": "ใหม่",
     "New": "ใหม่",
-    "Create": "สร้าง",
+    "Create": "สร้าง ",
     "Clone": "โคลน",
     "Clone": "โคลน",
     "Raw text": "ข้อความล้วน",
     "Raw text": "ข้อความล้วน",
     "Expires": "หมดอายุ",
     "Expires": "หมดอายุ",
@@ -133,9 +133,9 @@
         "เอกสารนี้จะหมดอายุใน %d เดือน",
         "เอกสารนี้จะหมดอายุใน %d เดือน",
         "เอกสารนี้จะหมดอายุใน %d เดือน"
         "เอกสารนี้จะหมดอายุใน %d เดือน"
     ],
     ],
-    "Please enter the password for this document:": "กรุณากรอกรหัสผ่านเพื่อเปิดข้อมูลการฝากโค้ดนี้:",
+    "Please enter the password for this document:": "ป้อนรหัสผ่านสำหรับเอกสารนี้:",
     "Could not decrypt data (Wrong key?)": "ไม่สามารถถอดรหัสข้อมูลได้ (คีย์ไม่ถูกต้องหรือไม่)",
     "Could not decrypt data (Wrong key?)": "ไม่สามารถถอดรหัสข้อมูลได้ (คีย์ไม่ถูกต้องหรือไม่)",
-    "Could not delete the document, it was not stored in burn after reading mode.": "ไม่สามารถลบการฝากโค้ดนี้ได้ เนื่องจากว่าไม่ได้ถูกเก็บไว้ในโหมดลบทันทีเมื่อเปิดอ่าน",
+    "Could not delete the document, it was not stored in burn after reading mode.": "ไม่สามารถลบเอกสารได้ เนื่องจากไม่ได้บันทึกไว้ในโหมดเขียนหลังจากอ่านแล้ว",
     "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.": "เก็บไว้ดูคนเดียวนะ อย่าปิดหน้าต่างนี้ ข้อความนี้จะไม่สามารถแสดงได้อีก",
     "Could not decrypt comment; Wrong key?": "ไม่สามารถถอดรหัสความคิดเห็นได้ คีย์ไม่ถูกต้องหรือไม่",
     "Could not decrypt comment; Wrong key?": "ไม่สามารถถอดรหัสความคิดเห็นได้ คีย์ไม่ถูกต้องหรือไม่",
     "Reply": "ตอบกลับ",
     "Reply": "ตอบกลับ",
@@ -150,13 +150,13 @@
     "unknown status": "ไม่ทราบสถานะ",
     "unknown status": "ไม่ทราบสถานะ",
     "server error or not responding": "เซิร์ฟเวอร์มีข้อผิดพลาดหรือไม่ตอบสนอง",
     "server error or not responding": "เซิร์ฟเวอร์มีข้อผิดพลาดหรือไม่ตอบสนอง",
     "Could not post comment: %s": "ไม่สามารถส่งความคิดเห็นได้: %s",
     "Could not post comment: %s": "ไม่สามารถส่งความคิดเห็นได้: %s",
-    "Sending document…": "กำลังส่งข้อมูล…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "การฝากโค้ดของคุณอยู่ที่ <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(กดปุ่ม <kbd>Ctrl</kbd>+<kbd>c</kbd> เพื่อคัดลอก)</span>",
+    "Sending document…": "กำลังส่งเอกสาร…",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "เอกสารของคุณคือ <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(กด <kbd>%s</kbd>+<kbd>c</kbd> เพื่อคัดลอก)</span>",
     "Delete data": "ลบข้อมูล",
     "Delete data": "ลบข้อมูล",
-    "Could not create document: %s": "ไม่สามารถสร้างข้อมูลการฝากโค้ดได้: %s",
-    "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "ไม่สามารถถอดรหัสข้อมูลการฝากโค้ดได้: คีย์ถอดรหัสที่อยู่ใน URL หายไป (คุณได้ใช้ตัวเปลี่ยนเส้นทางหรือตัวย่อ URL ที่มีการตัดส่วนของ URL ออกหรือไม่)",
+    "Could not create document: %s": "ไม่สามารถสร้างเอกสารได้: %s",
+    "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "ไม่สามารถถอดรหัสเอกสารได้: ไม่พบรหัสถอดรหัสใน URL (คุณใช้โปรแกรมเปลี่ยนเส้นทางหรือโปรแกรมย่อ URL ที่ตัดส่วนหนึ่งของ URL ออกหรือไม่ ?)",
     "B": "B",
     "B": "B",
-    "kB": "kB",
+    "kB": "kB ",
     "MB": "MB",
     "MB": "MB",
     "GB": "GB",
     "GB": "GB",
     "TB": "TB",
     "TB": "TB",
@@ -170,9 +170,9 @@
     "Markdown": "Markdown",
     "Markdown": "Markdown",
     "Download attachment": "ดาวน์โหลดไฟล์แนบ",
     "Download attachment": "ดาวน์โหลดไฟล์แนบ",
     "Cloned: '%s'": "โคลนแล้ว: '%s'",
     "Cloned: '%s'": "โคลนแล้ว: '%s'",
-    "The cloned file '%s' was attached to this document.": "การโคลนข้อมูลการฝากโค้ด มีไฟล์ '%s' แนบมาด้วย",
+    "The cloned file '%s' was attached to this document.": "ไฟล์ที่ถูกคัดลอก '%s' ถูกแนบมากับเอกสารนี้",
     "Attach a file": "แนบไฟล์",
     "Attach a file": "แนบไฟล์",
-    "alternatively drag & drop a file or paste an image from the clipboard": "หรือสามารถลากและวางไฟล์หรือวางรูปภาพจากคลิปบอร์ดได้",
+    "alternatively drag & drop a file or paste an image from the clipboard": "หรืออีกวิธีหนึ่งคือลากและวางไฟล์ หรือวางรูปภาพจากคลิปบอร์ด",
     "File too large, to display a preview. Please download the attachment.": "ไฟล์มีขนาดใหญ่เกินไปที่จะแสดงตัวอย่าง กรุณาดาวน์โหลดเป็นไฟล์แนบแทน",
     "File too large, to display a preview. Please download the attachment.": "ไฟล์มีขนาดใหญ่เกินไปที่จะแสดงตัวอย่าง กรุณาดาวน์โหลดเป็นไฟล์แนบแทน",
     "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.": "เบราว์เซอร์ของคุณไม่สนับสนุนการอัปโหลดไฟล์แบบเข้ารหัสได้ กรุณาใช้เบราว์เซอร์ที่ใหม่กว่า",
@@ -185,11 +185,11 @@
     "Decrypt": "ถอดรหัส",
     "Decrypt": "ถอดรหัส",
     "Enter password": "กรอกรหัสผ่าน",
     "Enter password": "กรอกรหัสผ่าน",
     "Loading…": "กำลังโหลด…",
     "Loading…": "กำลังโหลด…",
-    "Decrypting document…": "กำลังถอดรหัสข้อมูลการฝากโค้ด…",
-    "Preparing new document…": "กำลังเตรียมข้อมูลการฝากโค้ดใหม่…",
+    "Decrypting document…": "กำลังถอดรหัสเอกสาร…",
+    "Preparing new document…": "กำลังเตรียมเอกสารใหม่…",
     "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "ในกรณีที่ข้อความนี้ยังปรากฎให้เห็นอยู่ กรุณาดู<a href=\"%s\">คำถามที่พบบ่อยนี้เพื่อใช้แก้ไขปัญหา</a>",
     "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "ในกรณีที่ข้อความนี้ยังปรากฎให้เห็นอยู่ กรุณาดู<a href=\"%s\">คำถามที่พบบ่อยนี้เพื่อใช้แก้ไขปัญหา</a>",
-    "+++ no document text +++": "+++ ไม่มีข้อความการฝากโค้ด +++",
-    "Could not get document data: %s": "ไม่สามารถดึงข้อมูลการฝากโค้ดได้: %s",
+    "+++ no document text +++": "+++ ไม่มีข้อความในเอกสาร +++",
+    "Could not get document data: %s": "ไม่สามารถดึงข้อมูลเอกสารได้: %s",
     "QR code": "คิวอาร์โค้ด",
     "QR code": "คิวอาร์โค้ด",
     "This website is using an insecure HTTP connection! Please use it only for testing.": "เว็บไซต์นี้ใช้การเชื่อมต่อแบบ HTTP ที่ไม่ปลอดภัย! กรุณาใช้เพื่อการทดสอบเท่านั้น",
     "This website is using an insecure HTTP connection! Please use it only for testing.": "เว็บไซต์นี้ใช้การเชื่อมต่อแบบ HTTP ที่ไม่ปลอดภัย! กรุณาใช้เพื่อการทดสอบเท่านั้น",
     "For more information <a href=\"%s\">see this FAQ entry</a>.": "สำหรับข้อมูลเพิ่มเติม <a href=\"%s\">กรุณาดูรายการคำถามที่พบบ่อยนี้</a>",
     "For more information <a href=\"%s\">see this FAQ entry</a>.": "สำหรับข้อมูลเพิ่มเติม <a href=\"%s\">กรุณาดูรายการคำถามที่พบบ่อยนี้</a>",
@@ -210,27 +210,29 @@
     "Encrypted note on %s": "เขารหัสบันทึกย่อบน %s",
     "Encrypted note on %s": "เขารหัสบันทึกย่อบน %s",
     "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "ไปที่ลิงก์นี้เพื่อดูบันทึกย่อทั้งหมด ส่ง URL นี้ให้ใครก็ได้เพื่อให้สามารถเข้าถึงบันทึกย่อได้",
     "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "ไปที่ลิงก์นี้เพื่อดูบันทึกย่อทั้งหมด ส่ง URL นี้ให้ใครก็ได้เพื่อให้สามารถเข้าถึงบันทึกย่อได้",
     "URL shortener may expose your decrypt key in URL.": "เครื่องมือสร้างลิงก์ย่ออาจเปิดเผยคีย์ถอดรหัสของคุณใน URL ได้",
     "URL shortener may expose your decrypt key in URL.": "เครื่องมือสร้างลิงก์ย่ออาจเปิดเผยคีย์ถอดรหัสของคุณใน URL ได้",
-    "URL shortener is enabled by default.": "URL shortener is enabled by default.",
-    "Save document": "ดาวน์โหลดข้อมูลการฝากโค้ด",
-    "Your IP is not authorized to create documents.": "IP ของคุณไม่ได้รับอนุญาตให้สร้างการฝากโค้ด",
+    "URL shortener is enabled by default.": "ค่าเริ่มต้น ระบบย่อ URL จะเปิดใช้งานอยู่แล้ว",
+    "Save document": "บันทึกเอกสาร",
+    "Your IP is not authorized to create documents.": "IP ของคุณไม่ได้รับอนุญาตให้สร้างเอกสาร",
     "Trying to shorten a URL that isn't pointing at our instance.": "กำลังพยายามใช้เครื่องมือสร้างลิงก์ย่อ ที่ไม่ได้ชี้ไปที่อินสแตนซ์ของเรา",
     "Trying to shorten a URL that isn't pointing at our instance.": "กำลังพยายามใช้เครื่องมือสร้างลิงก์ย่อ ที่ไม่ได้ชี้ไปที่อินสแตนซ์ของเรา",
-    "Proxy error: Proxy URL is empty. This can be a configuration issue, like wrong or missing config keys.": "เกิดข้อผิดพลาดในการเรียก YOURLS อาจเป็นปัญหามาจากการกำหนดค่า เช่น \"apiurl\" หรือ \"signature\" ไม่ถูกต้องหรือขาดหายไป",
-    "Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.": "เกิดข้อผิดพลาดในการแยกวิเคราะห์การตอบสนองของ YOURLS",
-    "Proxy error: Bad response. This can be a configuration issue, like wrong or missing config keys or a temporary outage.": "Proxy error: Bad response. This can be a configuration issue, like wrong or missing config keys or a temporary outage.",
-    "This secret message can only be displayed once. Would you like to see it now?": "ข้อความลับนี้จะแสดงได้เพียงครั้งเดียวเท่านั้น คุณต้องการดูข้อความนี้ตอนนี้เลยใช่หรือไม่",
-    "Yes, see it": "ใช่ ดูเลย",
+    "Proxy error: Proxy URL is empty. This can be a configuration issue, like wrong or missing config keys.": "เกิดข้อผิดพลาดพร็อกซี: URL พร็อกซีว่างเปล่า นี่อาจเป็นปัญหาเกี่ยวกับการตั้งค่า เช่น คีย์การตั้งค่าไม่ถูกต้องหรือขาดหายไป",
+    "Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.": "เกิดข้อผิดพลาดพร็อกซี: เกิดข้อผิดพลาดในการวิเคราะห์การตอบสนองของพร็อกซี นี่อาจเป็นปัญหาเกี่ยวกับการกำหนดค่า เช่น คีย์การกำหนดค่าไม่ถูกต้องหรือขาดหายไป",
+    "Proxy error: Bad response. This can be a configuration issue, like wrong or missing config keys or a temporary outage.": "ข้อผิดพลาดพร็อกซี: การตอบสนองไม่ถูกต้อง นี่อาจเป็นปัญหาเกี่ยวกับการกำหนดค่า เช่น คีย์การกำหนดค่าไม่ถูกต้องหรือขาดหายไป หรือการหยุดชะงักชั่วคราว",
+    "This secret message can only be displayed once. Would you like to see it now?": "ข้อความลับนี้จะแสดงได้เพียงครั้งเดียว คุณต้องการดูตอนนี้หรือไม่?",
+    "Yes, see it": "ใช่ เห็นแล้ว",
     "Dark Mode": "โหมดสีเข้ม",
     "Dark Mode": "โหมดสีเข้ม",
-    "Error compressing document, due to missing WebAssembly support.": "ไม่สามารถบีบอัดข้อมูลที่คุณต้องการฝากโค้ดได้ เนื่องจากอุปกรณ์ของคุณขาดการรองรับ WebAssembly",
-    "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "ไม่สามารถอ่านข้อมูลที่คุณได้ฝากโค้ดไว้ เบราว์เซอร์ของคุณไม่รองรับ WebAssembly กรุณาลองเปลี่ยนใช้เบราว์เซอร์ตัวอื่นเพื่อดูการฝากโค้ดนี้อีกครั้ง",
-    "Start over": "เริ่มใหม่",
-    "Document copied to clipboard": "คัดลอกการฝากโค้ดไปที่คลิปบอร์ดแล้ว",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "หากต้องการคัดลอกและวาง กรุณากดปุ่มคัดลอก หรือใช้ปุ่มลัด <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd> สำหรับคลิปบอร์ด",
-    "Copy link": "คัดลอกลิงก์",
-    "Link copied to clipboard": "คัดลอกลิงก์ไปที่คลิปบอร์ดแล้ว",
-    "Document text": "ฝากข้อความ",
-    "Tabulator key serves as character (Hit <kbd>Ctrl</kbd>+<kbd>m</kbd> or <kbd>Esc</kbd> to toggle)": "ปุ่ม Tabulator ใช้เป็นอักขระ (กด <kbd>Ctrl</kbd>+<kbd>m</kbd> หรือ <kbd>Esc</kbd> เพื่อสลับ)",
-    "Show password": "Show password",
-    "Hide password": "Hide password",
-    "Theme": "ธีม",
-    "Copy": "Copy"
+    "Error compressing document, due to missing WebAssembly support.": "เกิดข้อผิดพลาดในการบีบอัดเอกสาร เนื่องจากขาดการสนับสนุน WebAssembly",
+    "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "เกิดข้อผิดพลาดในการคลายไฟล์เอกสาร เบราว์เซอร์ของคุณไม่รองรับ WebAssembly โปรดใช้เบราว์เซอร์อื่นเพื่อดูเอกสารนี้",
+    "Start over": "เริ่มต้นใหม่",
+    "Document copied to clipboard": "เอกสารถูกคัดลอกไปยังคลิปบอร์ดแล้ว",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "หากต้องการคัดลอกเอกสาร ให้กดปุ่มคัดลอก หรือใช้ทางลัดคลิปบอร์ด <kbd>%s</kbd>+<kbd>c</kbd>",
+    "Copy link": "คัดลอกที่อยู่ลิงก์",
+    "Link copied to clipboard": "คัดลอกที่อยู่ลิงก์ไปยังคลิปบอร์ดแล้ว",
+    "Document text": "เอกสารข้อความ",
+    "Tabulator key serves as character (Hit <kbd>Ctrl</kbd>+<kbd>m</kbd> or <kbd>Esc</kbd> to toggle)": "ปุ่ม Tabulator ทำหน้าที่เป็นอักขระ (กด <kbd>Ctrl</kbd>+<kbd>m</kbd> หรือ <kbd>Esc</kbd> เพื่อสลับ)",
+    "Show password": "แสดงรหัสผ่าน",
+    "Hide password": "ซ่อนรหัสผ่าน",
+    "Theme": "ธีม ",
+    "Copy": "คัดลอก",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/tr.json

@@ -151,7 +151,7 @@
     "server error or not responding": "sunucu hatası veya yanıt vermiyor",
     "server error or not responding": "sunucu hatası veya yanıt vermiyor",
     "Could not post comment: %s": "Yorum paylaşılamadı: %s",
     "Could not post comment: %s": "Yorum paylaşılamadı: %s",
     "Sending document…": "Yazı gönderiliyor…",
     "Sending document…": "Yazı gönderiliyor…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Yazınız: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(<kbd>Ctrl</kbd>+<kbd>c</kbd> tuşlarına basarak kopyalayın.)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Yazınız: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(<kbd>%s</kbd>+<kbd>c</kbd> tuşlarına basarak kopyalayın.)</span>",
     "Delete data": "Veriyi sil",
     "Delete data": "Veriyi sil",
     "Could not create document: %s": "Yazı oluşturulamadı: %s",
     "Could not create document: %s": "Yazı oluşturulamadı: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Yazı şifresi çözülemedi, çözme anahtarı URL'de bulunamadı. (Buraya bir yönlendirici veya URL kısaltıcı kullanarak gelmiş olabilirsiniz.)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Yazı şifresi çözülemedi, çözme anahtarı URL'de bulunamadı. (Buraya bir yönlendirici veya URL kısaltıcı kullanarak gelmiş olabilirsiniz.)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Yazı açılırken hata oluştu, tarayıcınız WebAssembly'i desteklemiyor. Lütfen bu yazıyı görüntülemek için başka bir tarayıcı kullanın.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Yazı açılırken hata oluştu, tarayıcınız WebAssembly'i desteklemiyor. Lütfen bu yazıyı görüntülemek için başka bir tarayıcı kullanın.",
     "Start over": "Baştan başla",
     "Start over": "Baştan başla",
     "Document copied to clipboard": "Document copied to clipboard",
     "Document copied to clipboard": "Document copied to clipboard",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Copy link",
     "Copy link": "Copy link",
     "Link copied to clipboard": "Link copied to clipboard",
     "Link copied to clipboard": "Link copied to clipboard",
     "Document text": "Document text",
     "Document text": "Document text",
@@ -232,5 +232,7 @@
     "Show password": "Show password",
     "Show password": "Show password",
     "Hide password": "Hide password",
     "Hide password": "Hide password",
     "Theme": "Theme",
     "Theme": "Theme",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 5 - 3
i18n/uk.json

@@ -151,7 +151,7 @@
     "server error or not responding": "помилка на сервері чи немає відповіді",
     "server error or not responding": "помилка на сервері чи немає відповіді",
     "Could not post comment: %s": "Не вдалося опублікувати коментар: %s",
     "Could not post comment: %s": "Не вдалося опублікувати коментар: %s",
     "Sending document…": "Надсилання документа…",
     "Sending document…": "Надсилання документа…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "Документ: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(копіювати: <kbd>Ctrl</kbd>+<kbd>c</kbd>)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "Документ: <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(копіювати: <kbd>%s</kbd>+<kbd>c</kbd>)</span>",
     "Delete data": "Видалити дані",
     "Delete data": "Видалити дані",
     "Could not create document: %s": "Не вдалося опублікувати документ: %s",
     "Could not create document: %s": "Не вдалося опублікувати документ: %s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Не вдається розшифрувати документ: ключ дешифрування відсутній у посиланні (можливо, ваш сервіс переспрямування чи скорочення посилань видаляє частину посилання?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Не вдається розшифрувати документ: ключ дешифрування відсутній у посиланні (можливо, ваш сервіс переспрямування чи скорочення посилань видаляє частину посилання?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Помилка розпакування документа: браузер не підтримує WebAssembly. Спробуйте відкрити документ в іншому браузері.",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Помилка розпакування документа: браузер не підтримує WebAssembly. Спробуйте відкрити документ в іншому браузері.",
     "Start over": "Почати знову",
     "Start over": "Почати знову",
     "Document copied to clipboard": "Документ скопійовано до буфера",
     "Document copied to clipboard": "Документ скопійовано до буфера",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "Щоб скопіювати документ, натисніть кнопку «Копіювати» чи клавіші <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "Щоб скопіювати документ, натисніть кнопку «Копіювати» чи клавіші <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Копіювати посилання",
     "Copy link": "Копіювати посилання",
     "Link copied to clipboard": "Посилання скопійовано до буфера",
     "Link copied to clipboard": "Посилання скопійовано до буфера",
     "Document text": "Текст документа",
     "Document text": "Текст документа",
@@ -232,5 +232,7 @@
     "Show password": "Показати пароль",
     "Show password": "Показати пароль",
     "Hide password": "Сховати пароль",
     "Hide password": "Сховати пароль",
     "Theme": "Тема",
     "Theme": "Тема",
-    "Copy": "Copy"
+    "Copy": "Copy",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 238 - 0
i18n/zh-tw.json

@@ -0,0 +1,238 @@
+{
+    "PrivateBin": "PrivateBin",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s 是一個極簡、開放原始碼的線上剪貼簿,伺服器對儲存的內容一無所知。所有資料皆%s在瀏覽器中%s以 AES-256 進行加、解密。",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "更多資訊請參閱<a href=\"https://privatebin.info/\">專案頁面</a>。",
+    "Because ignorance is bliss": "無知就是福",
+    "Document does not exist, has expired or has been deleted.": "文件不存在、已過期或已被刪除。",
+    "%s requires php %s or above to work. Sorry.": "%s 需要 PHP %s 或以上版本才能運作,造成不便敬請見諒。",
+    "%s requires configuration section [%s] to be present in configuration file.": "%s 需要在組態檔中設定 [%s] 區段。",
+    "Please wait %d seconds between each post.": [
+        "每次張貼之間請等待 %d 秒。",
+        "每次張貼之間請等待 %d 秒。",
+        "每次張貼之間請等待 %d 秒。",
+        "每次張貼之間請等待 %d 秒。",
+        "每次張貼之間請等待 %d 秒。",
+        "每次張貼之間請等待 %d 秒。"
+    ],
+    "Document is limited to %s of encrypted data.": "加密資料大小上限為 %s。",
+    "Invalid data.": "無效的資料。",
+    "You are unlucky. Try again.": "操作失敗,請再試一次。",
+    "Error saving comment. Sorry.": "儲存留言時發生錯誤,敬請見諒。",
+    "Error saving document. Sorry.": "儲存文件時發生錯誤,敬請見諒。",
+    "Invalid document ID.": "無效的文件 ID。",
+    "Document is not of burn-after-reading type.": "此文件並非閱後即焚類型。",
+    "Wrong deletion token. Document was not deleted.": "刪除權杖錯誤,文件未被刪除。",
+    "Document was properly deleted.": "文件已成功刪除。",
+    "JavaScript is required for %s to work. Sorry for the inconvenience.": "%s 需要 JavaScript 才能運作,造成不便敬請見諒。",
+    "%s requires a modern browser to work.": "%s 需要新式瀏覽器才能運作。",
+    "New": "新增",
+    "Create": "建立",
+    "Clone": "建立副本",
+    "Raw text": "原始文字",
+    "Expires": "到期時間",
+    "Burn after reading": "閱後即焚",
+    "Open discussion": "開放討論",
+    "Password (recommended)": "密碼 (建議使用)",
+    "Discussion": "討論",
+    "Toggle navigation": "切換導覽列",
+    "%d seconds": [
+        "%d 秒",
+        "%d 秒",
+        "%d 秒",
+        "%d 秒",
+        "%d 秒",
+        "%d 秒"
+    ],
+    "%d minutes": [
+        "%d 分鐘",
+        "%d 分鐘",
+        "%d 分鐘",
+        "%d 分鐘",
+        "%d 分鐘",
+        "%d 分鐘"
+    ],
+    "%d hours": [
+        "%d 小時",
+        "%d 小時",
+        "%d 小時",
+        "%d 小時",
+        "%d 小時",
+        "%d 小時"
+    ],
+    "%d days": [
+        "%d 天",
+        "%d 天",
+        "%d 天",
+        "%d 天",
+        "%d 天",
+        "%d 天"
+    ],
+    "%d weeks": [
+        "%d 週",
+        "%d 週",
+        "%d 週",
+        "%d 週",
+        "%d 週",
+        "%d 週"
+    ],
+    "%d months": [
+        "%d 個月",
+        "%d 個月",
+        "%d 個月",
+        "%d 個月",
+        "%d 個月",
+        "%d 個月"
+    ],
+    "%d years": [
+        "%d 年",
+        "%d 年",
+        "%d 年",
+        "%d 年",
+        "%d 年",
+        "%d 年"
+    ],
+    "Never": "永不過期",
+    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "注意:這是測試服務,資料可能隨時被刪除。如果濫用此服務,小貓咪會遭殃的。",
+    "This document will expire in %d seconds.": [
+        "此文件將在 %d 秒後過期。",
+        "此文件將在 %d 秒後過期。",
+        "此文件將在 %d 秒後過期。",
+        "此文件將在 %d 秒後過期。",
+        "此文件將在 %d 秒後過期。",
+        "此文件將在 %d 秒後過期。"
+    ],
+    "This document will expire in %d minutes.": [
+        "此文件將在 %d 分鐘後過期。",
+        "此文件將在 %d 分鐘後過期。",
+        "此文件將在 %d 分鐘後過期。",
+        "此文件將在 %d 分鐘後過期。",
+        "此文件將在 %d 分鐘後過期。",
+        "此文件將在 %d 分鐘後過期。"
+    ],
+    "This document will expire in %d hours.": [
+        "此文件將在 %d 小時後過期。",
+        "此文件將在 %d 小時後過期。",
+        "此文件將在 %d 小時後過期。",
+        "此文件將在 %d 小時後過期。",
+        "此文件將在 %d 小時後過期。",
+        "此文件將在 %d 小時後過期。"
+    ],
+    "This document will expire in %d days.": [
+        "此文件將在 %d 天後過期。",
+        "此文件將在 %d 天後過期。",
+        "此文件將在 %d 天後過期。",
+        "此文件將在 %d 天後過期。",
+        "此文件將在 %d 天後過期。",
+        "此文件將在 %d 天後過期。"
+    ],
+    "This document will expire in %d months.": [
+        "此文件將在 %d 個月後過期。",
+        "此文件將在 %d 個月後過期。",
+        "此文件將在 %d 個月後過期。",
+        "此文件將在 %d 個月後過期。",
+        "此文件將在 %d 個月後過期。",
+        "此文件將在 %d 個月後過期。"
+    ],
+    "Please enter the password for this document:": "請輸入此文件的密碼:",
+    "Could not decrypt data (Wrong key?)": "無法解密資料 (金鑰錯誤?)",
+    "Could not delete the document, it was not stored in burn after reading mode.": "無法刪除此文件,因為並非以閱後即焚模式儲存。",
+    "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.": "僅供閱覽一次。請勿關閉此視窗,此訊息將無法再次顯示。",
+    "Could not decrypt comment; Wrong key?": "無法解密留言;金鑰錯誤?",
+    "Reply": "回覆",
+    "Anonymous": "匿名",
+    "Avatar generated from IP address": "根據 IP 位址產生的頭像",
+    "Add comment": "新增留言",
+    "Optional nickname…": "暱稱 (選填)…",
+    "Post comment": "發表留言",
+    "Sending comment…": "正在傳送留言…",
+    "Comment posted.": "留言已發表。",
+    "Could not refresh display: %s": "無法重新整理顯示內容:%s",
+    "unknown status": "未知狀態",
+    "server error or not responding": "伺服器錯誤或無回應",
+    "Could not post comment: %s": "無法發表留言:%s",
+    "Sending document…": "正在傳送文件…",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "文件連結為 <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(按 <kbd>%s</kbd>+<kbd>c</kbd> 複製)</span>",
+    "Delete data": "刪除資料",
+    "Could not create document: %s": "無法建立文件:%s",
+    "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "無法解密文件:URL 中缺少解密金鑰 (是否使用了會截斷 URL 的重新導向服務或短網址服務?)",
+    "B": "B",
+    "kB": "kB",
+    "MB": "MB",
+    "GB": "GB",
+    "TB": "TB",
+    "PB": "PB",
+    "EB": "EB",
+    "ZB": "ZB",
+    "YB": "YB",
+    "Format": "格式",
+    "Plain Text": "純文字",
+    "Source Code": "原始碼",
+    "Markdown": "Markdown",
+    "Download attachment": "下載附件",
+    "Cloned: '%s'": "已建立副本:「%s」",
+    "The cloned file '%s' was attached to this document.": "已將副本檔案「%s」附加到此文件。",
+    "Attach a file": "附加檔案",
+    "alternatively drag & drop a file or paste an image from the clipboard": "或拖放檔案,亦可從剪貼簿貼上圖片",
+    "File too large, to display a preview. Please download the attachment.": "檔案過大,無法顯示預覽。請下載附件。",
+    "Remove attachment": "移除附件",
+    "Your browser does not support uploading encrypted files. Please use a newer browser.": "瀏覽器不支援上傳加密檔案,請使用較新的瀏覽器。",
+    "Invalid attachment.": "無效的附件。",
+    "Options": "選項",
+    "Shorten URL": "縮短網址",
+    "Editor": "編輯器",
+    "Preview": "預覽",
+    "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": "%s 的 PATH 必須以「%s」結尾,請更新 index.php 中的 PATH。",
+    "Decrypt": "解密",
+    "Enter password": "輸入密碼",
+    "Loading…": "載入中…",
+    "Decrypting document…": "正在解密文件…",
+    "Preparing new document…": "正在準備新文件…",
+    "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "若此訊息持續顯示,請參閱<a href=\"%s\">常見問題的疑難排解說明</a>。",
+    "+++ no document text +++": "+++ 無文件內容 +++",
+    "Could not get document data: %s": "無法取得文件資料:%s",
+    "QR code": "QR 碼",
+    "This website is using an insecure HTTP connection! Please use it only for testing.": "此網站正在使用不安全的 HTTP 連線!請僅用於測試。",
+    "For more information <a href=\"%s\">see this FAQ entry</a>.": "如需更多資訊,請參閱<a href=\"%s\">此常見問題</a>。",
+    "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.": "瀏覽器可能需要 HTTPS 連線才能支援 WebCrypto API。請嘗試<a href=\"%s\">切換至 HTTPS</a>。",
+    "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.": "瀏覽器不支援用於 zlib 壓縮的 WebAssembly。可以建立未壓縮的文件,但無法讀取已壓縮的文件。",
+    "waiting on user to provide a password": "等待輸入密碼",
+    "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.": "無法解密資料。是否輸入了錯誤的密碼?請按上方的 [重試] 按鈕重新嘗試。",
+    "Retry": "重試",
+    "Showing raw text…": "正在顯示原始文字…",
+    "Notice:": "注意:",
+    "This link will expire after %s.": "此連結將於 %s 後過期。",
+    "This link can only be accessed once, do not use back or refresh button in your browser.": "此連結僅能存取一次,請勿使用瀏覽器的 [上一頁] 或 [重新整理] 按鈕。",
+    "Link:": "連結:",
+    "Recipient may become aware of your timezone, convert time to UTC?": "收件者可能會得知您的時區,是否將時間轉換為 UTC?",
+    "Use Current Timezone": "使用目前時區",
+    "Convert To UTC": "轉換為 UTC",
+    "Close": "關閉",
+    "Encrypted note on %s": "%s 上的加密筆記",
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "請造訪此連結檢視筆記。將此 URL 提供給任何人即可讓對方存取這則筆記。",
+    "URL shortener may expose your decrypt key in URL.": "短網址服務可能會在 URL 中暴露解密金鑰。",
+    "URL shortener is enabled by default.": "預設已啟用短網址功能。",
+    "Save document": "儲存文件",
+    "Your IP is not authorized to create documents.": "您的 IP 無權建立文件。",
+    "Trying to shorten a URL that isn't pointing at our instance.": "嘗試縮短的 URL 並非指向本站。",
+    "Proxy error: Proxy URL is empty. This can be a configuration issue, like wrong or missing config keys.": "代理伺服器錯誤:代理伺服器 URL 為空。這可能是組態問題,例如組態參數錯誤或遺失。",
+    "Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.": "代理伺服器錯誤:剖析代理伺服器回應時發生錯誤。這可能是組態問題,例如組態參數錯誤或遺失。",
+    "Proxy error: Bad response. This can be a configuration issue, like wrong or missing config keys or a temporary outage.": "代理伺服器錯誤:回應無效。這可能是組態問題,例如組態參數錯誤或遺失,或是暫時性中斷。",
+    "This secret message can only be displayed once. Would you like to see it now?": "這則機密訊息僅能顯示一次。是否立即檢視?",
+    "Yes, see it": "是,立即檢視",
+    "Dark Mode": "深色模式",
+    "Error compressing document, due to missing WebAssembly support.": "由於缺少 WebAssembly 支援,壓縮文件時發生錯誤。",
+    "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "解壓縮文件時發生錯誤,瀏覽器不支援 WebAssembly。請使用其他瀏覽器檢視此文件。",
+    "Start over": "重新開始",
+    "Document copied to clipboard": "文件已複製到剪貼簿",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "若要複製文件,請按 [複製] 按鈕或使用快速鍵 <kbd>%s</kbd>+<kbd>c</kbd>",
+    "Copy link": "複製連結",
+    "Link copied to clipboard": "連結已複製到剪貼簿",
+    "Document text": "文件內容",
+    "Tabulator key serves as character (Hit <kbd>Ctrl</kbd>+<kbd>m</kbd> or <kbd>Esc</kbd> to toggle)": "Tab 鍵作為字元輸入 (按 <kbd>Ctrl</kbd>+<kbd>m</kbd> 或 <kbd>Esc</kbd> 切換)",
+    "Show password": "顯示密碼",
+    "Hide password": "隱藏密碼",
+    "Theme": "佈景主題",
+    "Copy": "複製",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
+}

+ 5 - 3
i18n/zh.json

@@ -151,7 +151,7 @@
     "server error or not responding": "服务器错误或无回应",
     "server error or not responding": "服务器错误或无回应",
     "Could not post comment: %s": "无法发送评论: %s",
     "Could not post comment: %s": "无法发送评论: %s",
     "Sending document…": "正在发送粘贴内容…",
     "Sending document…": "正在发送粘贴内容…",
-    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>": "您粘贴内容的链接是 <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(按下 <kbd>Ctrl</kbd>+<kbd>c</kbd> 以复制)</span>",
+    "Your document is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit <kbd>%s</kbd>+<kbd>c</kbd> to copy)</span>": "您粘贴内容的链接是 <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(按下 <kbd>%s</kbd>+<kbd>c</kbd> 以复制)</span>",
     "Delete data": "删除数据",
     "Delete data": "删除数据",
     "Could not create document: %s": "无法创建粘贴:%s",
     "Could not create document: %s": "无法创建粘贴:%s",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "无法解密粘贴:URL中缺失解密密钥(是否使用了重定向或者短链接导致密钥丢失?)",
     "Cannot decrypt document: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "无法解密粘贴:URL中缺失解密密钥(是否使用了重定向或者短链接导致密钥丢失?)",
@@ -224,7 +224,7 @@
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "解压粘贴时出错,您的浏览器不支持 WebAssembly。请使用其他浏览器查看此粘贴。",
     "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "解压粘贴时出错,您的浏览器不支持 WebAssembly。请使用其他浏览器查看此粘贴。",
     "Start over": "重新开始",
     "Start over": "重新开始",
     "Document copied to clipboard": "粘贴内容已复制到剪贴板",
     "Document copied to clipboard": "粘贴内容已复制到剪贴板",
-    "To copy document press on the copy button or use the clipboard shortcut <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>": "要复制粘贴内容,请点击复制按钮或使用快捷键 <kbd>Ctrl</kbd>+<kbd>c</kbd>/<kbd>Cmd</kbd>+<kbd>c</kbd>。",
+    "To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>": "要复制粘贴内容,请点击复制按钮或使用快捷键 <kbd>%s</kbd>+<kbd>c</kbd>。",
     "Copy link": "复制链接",
     "Copy link": "复制链接",
     "Link copied to clipboard": "链接已复制到剪贴板",
     "Link copied to clipboard": "链接已复制到剪贴板",
     "Document text": "粘贴文本",
     "Document text": "粘贴文本",
@@ -232,5 +232,7 @@
     "Show password": "显示密码",
     "Show password": "显示密码",
     "Hide password": "隐藏密码",
     "Hide password": "隐藏密码",
     "Theme": "主题",
     "Theme": "主题",
-    "Copy": "复制"
+    "Copy": "复制",
+    "Cmd": "Cmd",
+    "Ctrl": "Ctrl"
 }
 }

+ 0 - 2
js/.istanbul.yml

@@ -1,7 +1,5 @@
 ---
 ---
 instrumentation:
 instrumentation:
-  excludes:
-    - jquery-3.7.1.js
   baseline-file: ../tst/log/js-coverage-baseline.json
   baseline-file: ../tst/log/js-coverage-baseline.json
 reporting:
 reporting:
   dir: ../tst/log/js-coverage-report
   dir: ../tst/log/js-coverage-report

Plik diff jest za duży
+ 0 - 5
js/bootstrap-3.4.1.js


+ 53 - 72
js/common.js

@@ -2,23 +2,40 @@
 
 
 // testing prerequisites
 // testing prerequisites
 global.assert = require('assert');
 global.assert = require('assert');
-global.jsc = require('jsverify');
+const fc = require('fast-check');
 global.jsdom = require('jsdom-global');
 global.jsdom = require('jsdom-global');
-global.cleanup = global.jsdom();
+// initial DOM environment created by jsdom-global
+let currentCleanup = global.jsdom();
+
+// Recreates the jsdom environment and reloads privatebin.js into it.
+// The reload is necessary because modules close over DOM element references
+// in their init() methods, and pending async callbacks (e.g. setTimeout in
+// CopyToClipboard) would otherwise fire against elements from a dead window.
+global.cleanup = function (...args) {
+    if (typeof currentCleanup === 'function') {
+        currentCleanup();
+    }
+    currentCleanup = global.jsdom(...args);
+    delete require.cache[require.resolve('./privatebin')];
+    delete require.cache[require.resolve('./legacy')];
+    require('./privatebin');
+    global.PrivateBin = window.PrivateBin;
+    return global.cleanup;
+};
 global.fs = require('fs');
 global.fs = require('fs');
 global.WebCrypto = require('@peculiar/webcrypto').Crypto;
 global.WebCrypto = require('@peculiar/webcrypto').Crypto;
 
 
 // application libraries to test
 // application libraries to test
-global.$ = global.jQuery = require('./jquery-3.7.1');
 global.zlib = require('./zlib').zlib;
 global.zlib = require('./zlib').zlib;
 require('./prettify');
 require('./prettify');
-global.prettyPrint = window.PR.prettyPrint;
-global.prettyPrintOne = window.PR.prettyPrintOne;
+global.prettyPrint = window.PR ? window.PR.prettyPrint : function() {};
+global.prettyPrintOne = window.PR ? window.PR.prettyPrintOne : function() {};
 global.showdown = require('./showdown-2.1.0');
 global.showdown = require('./showdown-2.1.0');
-global.DOMPurify = require('./purify-3.4.1');
+global.DOMPurify = require('./purify-3.4.12');
 global.baseX = require('./base-x-5.0.1').baseX;
 global.baseX = require('./base-x-5.0.1').baseX;
 global.Legacy = require('./legacy').Legacy;
 global.Legacy = require('./legacy').Legacy;
 require('./privatebin');
 require('./privatebin');
+global.PrivateBin = window.PrivateBin;
 
 
 // internal variables
 // internal variables
 var a2zString    = ['a','b','c','d','e','f','g','h','i','j','k','l','m',
 var a2zString    = ['a','b','c','d','e','f','g','h','i','j','k','l','m',
@@ -34,46 +51,9 @@ var a2zString    = ['a','b','c','d','e','f','g','h','i','j','k','l','m',
         })
         })
     ),
     ),
     schemas = ['ftp','http','https'],
     schemas = ['ftp','http','https'],
-    supportedLanguages = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ru', 'sk', 'sl', 'th', 'tr', 'uk', 'zh'],
-    mimeTypes = ['image/png', 'application/octet-stream'],
-    formats = ['plaintext', 'markdown', 'syntaxhighlighting'],
-    mimeFile = fs.createReadStream('/etc/mime.types'),
-    mimeLine = '';
-
-// populate mime types from environment
-mimeFile.on('data', function(data) {
-    mimeLine += data;
-    var index = mimeLine.indexOf('\n');
-    while (index > -1) {
-        var line = mimeLine.substring(0, index);
-        mimeLine = mimeLine.substring(index + 1);
-        parseMime(line);
-        index = mimeLine.indexOf('\n');
-    }
-});
-
-mimeFile.on('end', function() {
-    if (mimeLine.length > 0) {
-        parseMime(mimeLine);
-    }
-});
-
-function parseMime(line) {
-    // ignore comments
-    var index = line.indexOf('#');
-    if (index > -1) {
-        line = line.substring(0, index);
-    }
-
-    // ignore bits after tabs
-    index = line.indexOf('\t');
-    if (index > -1) {
-        line = line.substring(0, index);
-    }
-    if (line.length > 0) {
-        mimeTypes.push(line);
-    }
-}
+    supportedLanguages = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ru', 'sk', 'sl', 'th', 'tr', 'uk', 'zh', 'zh-tw'],
+    mimeTypes = ['image/png', 'application/octet-stream'].concat(Object.keys(require('mime-db'))),
+    formats = ['plaintext', 'markdown', 'syntaxhighlighting'];
 
 
 // common testing helper functions
 // common testing helper functions
 // as of jsDOM 22 the base64 functions provided in the DOM are more restrictive
 // as of jsDOM 22 the base64 functions provided in the DOM are more restrictive
@@ -88,68 +68,68 @@ exports.btoa = function(text) {
 };
 };
 
 
 // provides random lowercase characters from a to z
 // provides random lowercase characters from a to z
-exports.jscA2zString = function() {
-    return jsc.elements(a2zString);
+exports.fcA2zString = function() {
+    return fc.constantFrom(...a2zString);
 };
 };
 
 
 // provides random lowercase alpha numeric characters (a to z and 0 to 9)
 // provides random lowercase alpha numeric characters (a to z and 0 to 9)
-exports.jscAlnumString = function() {
-    return jsc.elements(alnumString);
+exports.fcAlnumString = function() {
+    return fc.constantFrom(...alnumString);
 };
 };
 
 
 //provides random characters allowed in hexadecimal notation
 //provides random characters allowed in hexadecimal notation
-exports.jscHexString = function() {
-    return jsc.elements(hexString);
+exports.fcHexString = function() {
+    return fc.constantFrom(...hexString);
 };
 };
 
 
 // provides random characters allowed in GET queries
 // provides random characters allowed in GET queries
-exports.jscQueryString = function() {
-    return jsc.elements(queryString);
+exports.fcQueryString = function() {
+    return fc.constantFrom(...queryString);
 };
 };
 
 
 // provides random characters allowed in hash queries
 // provides random characters allowed in hash queries
-exports.jscHashString = function() {
-    return jsc.elements(hashString);
+exports.fcHashString = function() {
+    return fc.constantFrom(...hashString);
 };
 };
 
 
 // provides random characters allowed in base64 encoded strings
 // provides random characters allowed in base64 encoded strings
-exports.jscBase64String = function() {
-    return jsc.elements(base64String);
+exports.fcBase64String = function() {
+    return fc.constantFrom(...base64String);
 };
 };
 
 
 // provides a random URL schema supported by the whatwg-url library
 // provides a random URL schema supported by the whatwg-url library
-exports.jscSchemas = function(withFtp = true) {
-    return jsc.elements(withFtp ? schemas : schemas.slice(1));
+exports.fcSchemas = function(withFtp = true) {
+    return fc.constantFrom(...(withFtp ? schemas : schemas.slice(1)));
 };
 };
 
 
 // provides a random supported language string
 // provides a random supported language string
-exports.jscSupportedLanguages = function() {
-    return jsc.elements(supportedLanguages);
+exports.fcSupportedLanguages = function() {
+    return fc.constantFrom(...supportedLanguages);
 };
 };
 
 
 // provides a random mime type
 // provides a random mime type
-exports.jscMimeTypes = function() {
-    return jsc.elements(mimeTypes);
+exports.fcMimeTypes = function() {
+    return fc.constantFrom(...mimeTypes);
 };
 };
 
 
 // provides a random PrivateBin document formatter
 // provides a random PrivateBin document formatter
-exports.jscFormats = function() {
-    return jsc.elements(formats);
+exports.fcFormats = function() {
+    return fc.constantFrom(...formats);
 };
 };
 
 
 // provides random URLs
 // provides random URLs
-exports.jscUrl = function(withFragment = true, withQuery = true) {
+exports.fcUrl = function(withFragment = true, withQuery = true) {
     let url = {
     let url = {
-        schema: exports.jscSchemas(),
-        address: jsc.nearray(exports.jscA2zString()),
+        schema: exports.fcSchemas(),
+        address: fc.array(exports.fcA2zString(), {minLength: 1}),
     };
     };
     if (withFragment) {
     if (withFragment) {
-        url.fragment = jsc.string;
+        url.fragment = fc.string();
     }
     }
     if(withQuery) {
     if(withQuery) {
-        url.query = jsc.array(exports.jscQueryString());
+        url.query = fc.array(exports.fcQueryString());
     }
     }
-    return jsc.record(url);
+    return fc.record(url);
 };
 };
 
 
 exports.urlToString = function (url) {
 exports.urlToString = function (url) {
@@ -159,6 +139,7 @@ exports.urlToString = function (url) {
 };
 };
 
 
 exports.enableClipboard = function () {
 exports.enableClipboard = function () {
+    // @ts-ignore
     navigator.clipboard = (function () {
     navigator.clipboard = (function () {
         let savedText = "";
         let savedText = "";
 
 

+ 2 - 2
js/eslint.config.js

@@ -1,5 +1,5 @@
 const globals = require('globals');
 const globals = require('globals');
-const { globalIgnores } = require('eslint/config')
+const { globalIgnores } = require('eslint/config');
 
 
 module.exports = [globalIgnores(["./*.*js", "!./privatebin.js"]), {
 module.exports = [globalIgnores(["./*.*js", "!./privatebin.js"]), {
     languageOptions: {
     languageOptions: {
@@ -111,7 +111,7 @@ module.exports = [globalIgnores(["./*.*js", "!./privatebin.js"]), {
         yoda: 0,
         yoda: 0,
 
 
         // Strict
         // Strict
-        strict: 0,
+        strict: 2,
 
 
         // Variables
         // Variables
         "init-declarations": 0,
         "init-declarations": 0,

Plik diff jest za duży
+ 0 - 1
js/jquery-3.7.1.js


+ 22 - 0
js/jsconfig.json

@@ -0,0 +1,22 @@
+{
+  "compilerOptions": {
+    "target": "es2018",
+    "module": "NodeNext",
+    "moduleResolution": "nodenext",
+    "resolveJsonModule": true,
+    "checkJs": true,
+    "strict": true,
+    "noImplicitAny": false,
+    "forceConsistentCasingInFileNames": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "baseUrl": "./js",
+    "paths": {
+      "/*": ["./*"],
+    }
+  },
+  "exclude": [
+    "node_modules",
+    "**/node_modules/*"
+  ]
+}

+ 8 - 9
js/legacy.js

@@ -13,7 +13,6 @@
 /**
 /**
  * IMPORTANT NOTICE FOR DEVELOPERS:
  * IMPORTANT NOTICE FOR DEVELOPERS:
  * The logic in this file is intended to run in legacy browsers. Avoid any use of:
  * The logic in this file is intended to run in legacy browsers. Avoid any use of:
- * - jQuery (doesn't work in older browsers)
  * - ES5 or newer in general
  * - ES5 or newer in general
  * - const/let, use the traditional var declarations instead
  * - const/let, use the traditional var declarations instead
  * - async/await or Promises, use traditional callbacks
  * - async/await or Promises, use traditional callbacks
@@ -68,26 +67,26 @@
             'Spider',
             'Spider',
             'scraper',
             'scraper',
             'Scraper',
             'Scraper',
-            
+
             // Search Engines
             // Search Engines
             'Mediapartners-Google',
             'Mediapartners-Google',
             'BingPreview',
             'BingPreview',
             'Yahoo! Slurp',
             'Yahoo! Slurp',
-            
+
             // SEO & Analytics
             // SEO & Analytics
             'Screaming Frog',
             'Screaming Frog',
-            
+
             // Social Media
             // Social Media
             'facebookexternalhit',
             'facebookexternalhit',
-            
+
             // AI & LLM
             // AI & LLM
             'ChatGPT-User',
             'ChatGPT-User',
             'anthropic-ai',
             'anthropic-ai',
-            
+
             // Security Scanners
             // Security Scanners
             'CensysInspect',
             'CensysInspect',
             'Shodan',
             'Shodan',
-            
+
             // Other Common Crawlers
             // Other Common Crawlers
             '80legs',
             '80legs',
             'ia_archiver',
             'ia_archiver',
@@ -171,7 +170,7 @@
          * @private
          * @private
          * @name   Check.isBadBot
          * @name   Check.isBadBot
          * @function
          * @function
-         * @return {bool}
+         * @return {Boolean}
          */
          */
         function isBadBot() {
         function isBadBot() {
             // check whether a bot user agent part can be found in the current
             // check whether a bot user agent part can be found in the current
@@ -262,7 +261,7 @@
          *
          *
          * @name   Check.getStatus
          * @name   Check.getStatus
          * @function
          * @function
-         * @return {bool}
+         * @return {Boolean}
          */
          */
         me.getStatus = function()
         me.getStatus = function()
         {
         {

Plik diff jest za duży
+ 362 - 275
js/package-lock.json


+ 5 - 2
js/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "privatebin",
   "name": "privatebin",
-  "version": "2.0.4",
+  "version": "2.0.6",
   "description": "PrivateBin is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted in the browser using 256 bit AES in Galois Counter mode (GCM).",
   "description": "PrivateBin is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted in the browser using 256 bit AES in Galois Counter mode (GCM).",
   "main": "privatebin.js",
   "main": "privatebin.js",
   "directories": {
   "directories": {
@@ -9,14 +9,17 @@
   "devDependencies": {
   "devDependencies": {
     "@peculiar/webcrypto": "^1.5.0",
     "@peculiar/webcrypto": "^1.5.0",
     "eslint": "^9.37.0",
     "eslint": "^9.37.0",
+    "fast-check": "^4.7.0",
     "jsdom": "^26.1.0",
     "jsdom": "^26.1.0",
     "jsdom-global": "^3.0.2",
     "jsdom-global": "^3.0.2",
+    "jshint": "^2.13.6",
     "jsverify": "^0.8.3",
     "jsverify": "^0.8.3",
     "mime-types": "^3.0.2",
     "mime-types": "^3.0.2",
-    "mocha": "^11.7.5",
+    "mocha": "^12.0.0",
     "nyc": "^18.0.0"
     "nyc": "^18.0.0"
   },
   },
   "scripts": {
   "scripts": {
+    "lint": "eslint",
     "test": "mocha",
     "test": "mocha",
     "ci-test": "mocha --reporter xunit --reporter-option output=mocha-results.xml"
     "ci-test": "mocha --reporter xunit --reporter-option output=mocha-results.xml"
   },
   },

Plik diff jest za duży
+ 213 - 266
js/privatebin.js


Plik diff jest za duży
+ 0 - 1
js/purify-3.4.1.js


Plik diff jest za duży
+ 1 - 0
js/purify-3.4.12.js


+ 322 - 323
js/test/Alert.js

@@ -1,313 +1,313 @@
 'use strict';
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 
 describe('Alert', function () {
 describe('Alert', function () {
     describe('showStatus', function () {
     describe('showStatus', function () {
-        jsc.property(
-            'shows a status message (basic)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="status">' + message + '</div>';
-                $('body').html(
-                    '<div id="status"></div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showStatus(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a status message (basic)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="status">' + message + '</div>';
+                    document.body.innerHTML =
+                        '<div id="status"></div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showStatus(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
 
-        jsc.property(
-            'shows a status message (bootstrap)',
-            jsc.array(common.jscAlnumString()),
-            function (message) {
-                message = message.join('');
-                const expected = '<div id="status" role="alert" ' +
-                    'class="statusmessage alert alert-info"><span ' +
-                    'class="glyphicon glyphicon-info-sign" ' +
-                    'aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="status" role="alert" class="statusmessage ' +
-                    'alert alert-info hidden"><span class="glyphicon ' +
-                    'glyphicon-info-sign" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showStatus(message);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a status message (bootstrap)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                function (message) {
+                    message = message.join('');
+                    const expected = '<div id="status" role="alert" ' +
+                        'class="statusmessage alert alert-info"><span ' +
+                        'class="glyphicon glyphicon-info-sign" ' +
+                        'aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="status" role="alert" class="statusmessage ' +
+                        'alert alert-info hidden"><span class="glyphicon ' +
+                        'glyphicon-info-sign" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showStatus(message);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
 
-        jsc.property(
-            'shows a status message (bootstrap, custom icon)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="status" role="alert" ' +
-                    'class="statusmessage alert alert-info"><span ' +
-                    'class="glyphicon glyphicon-' + icon +
-                    '" aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="status" role="alert" class="statusmessage ' +
-                    'alert alert-info hidden"><span class="glyphicon ' +
-                    'glyphicon-info-sign" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showStatus(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a status message (bootstrap, custom icon)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="status" role="alert" ' +
+                        'class="statusmessage alert alert-info"><span ' +
+                        'class="glyphicon glyphicon-' + icon +
+                        '" aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="status" role="alert" class="statusmessage ' +
+                        'alert alert-info hidden"><span class="glyphicon ' +
+                        'glyphicon-info-sign" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showStatus(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
     });
     });
 
 
     describe('showWarning', function () {
     describe('showWarning', function () {
-        jsc.property(
-            'shows a warning message (basic)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="errormessage">' + message + '</div>';
-                $('body').html(
-                    '<div id="errormessage"></div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showWarning(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a warning message (basic)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="errormessage">' + message + '</div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage"></div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showWarning(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
 
-        jsc.property(
-            'shows a warning message (bootstrap)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (message) {
-                message = message.join('');
-                const expected = '<div id="errormessage" role="alert" ' +
-                    'class="statusmessage alert alert-danger"><span ' +
-                    'class="glyphicon glyphicon-warning-sign" ' +
-                    'aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="errormessage" role="alert" class="statusmessage ' +
-                    'alert alert-danger hidden"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showWarning(message);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a warning message (bootstrap)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (message) {
+                    message = message.join('');
+                    const expected = '<div id="errormessage" role="alert" ' +
+                        'class="statusmessage alert alert-danger"><span ' +
+                        'class="glyphicon glyphicon-warning-sign" ' +
+                        'aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage" role="alert" class="statusmessage ' +
+                        'alert alert-danger hidden"><span class="glyphicon ' +
+                        'glyphicon-alert" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showWarning(message);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
 
-        jsc.property(
-            'shows a warning message (bootstrap, custom icon)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="errormessage" role="alert" ' +
-                    'class="statusmessage alert alert-danger"><span ' +
-                    'class="glyphicon glyphicon-' + icon +
-                    '" aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="errormessage" role="alert" class="statusmessage ' +
-                    'alert alert-danger hidden"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showWarning(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a warning message (bootstrap, custom icon)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="errormessage" role="alert" ' +
+                        'class="statusmessage alert alert-danger"><span ' +
+                        'class="glyphicon glyphicon-' + icon +
+                        '" aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage" role="alert" class="statusmessage ' +
+                        'alert alert-danger hidden"><span class="glyphicon ' +
+                        'glyphicon-alert" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showWarning(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
     });
     });
 
 
     describe('showError', function () {
     describe('showError', function () {
-        jsc.property(
-            'shows an error message (basic)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="errormessage">' + message + '</div>';
-                $('body').html(
-                    '<div id="errormessage"></div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showError(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows an error message (basic)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="errormessage">' + message + '</div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage"></div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showError(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
 
-        jsc.property(
-            'shows an error message (bootstrap)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                message = message.join('');
-                const expected = '<div id="errormessage" role="alert" ' +
-                    'class="statusmessage alert alert-danger"><span ' +
-                    'class="glyphicon glyphicon-alert" ' +
-                    'aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="errormessage" role="alert" class="statusmessage ' +
-                    'alert alert-danger hidden"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showError(message);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows an error message (bootstrap)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    message = message.join('');
+                    const expected = '<div id="errormessage" role="alert" ' +
+                        'class="statusmessage alert alert-danger"><span ' +
+                        'class="glyphicon glyphicon-alert" ' +
+                        'aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage" role="alert" class="statusmessage ' +
+                        'alert alert-danger hidden"><span class="glyphicon ' +
+                        'glyphicon-alert" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showError(message);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
 
-        jsc.property(
-            'shows an error message (bootstrap, custom icon)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="errormessage" role="alert" ' +
-                    'class="statusmessage alert alert-danger"><span ' +
-                    'class="glyphicon glyphicon-' + icon +
-                    '" aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="errormessage" role="alert" class="statusmessage ' +
-                    'alert alert-danger hidden"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showError(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows an error message (bootstrap, custom icon)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="errormessage" role="alert" ' +
+                        'class="statusmessage alert alert-danger"><span ' +
+                        'class="glyphicon glyphicon-' + icon +
+                        '" aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage" role="alert" class="statusmessage ' +
+                        'alert alert-danger hidden"><span class="glyphicon ' +
+                        'glyphicon-alert" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showError(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
     });
     });
 
 
     describe('showRemaining', function () {
     describe('showRemaining', function () {
-        jsc.property(
-            'shows remaining time (basic)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            'integer',
-            function (message, string, number) {
-                message = message.join('');
-                string = string.join('');
-                const expected = '<div id="remainingtime" class="">' + string + message + number + '</div>';
-                $('body').html(
-                    '<div id="remainingtime" class="hidden"></div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showRemaining(['%s' + message + '%d', string, number]);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows remaining time (basic)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                fc.integer(),
+                function (message, string, number) {
+                    message = message.join('');
+                    string = string.join('');
+                    const expected = '<div id="remainingtime" class="">' + string + message + number + '</div>';
+                    document.body.innerHTML =
+                        '<div id="remainingtime" class="hidden"></div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showRemaining(['%s' + message + '%d', string, number]);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
 
-        jsc.property(
-            'shows remaining time (bootstrap)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            'integer',
-            function (message, string, number) {
-                message = message.join('');
-                string = string.join('');
-                const expected = '<div id="remainingtime" role="alert" ' +
-                    'class="alert alert-info"><span ' +
-                    'class="glyphicon glyphicon-fire" aria-hidden="true">' +
-                    '</span> <span>' + string + message + number + '</span></div>';
-                $('body').html(
-                    '<div id="remainingtime" role="alert" class="hidden ' +
-                    'alert alert-info"><span class="glyphicon ' +
-                    'glyphicon-fire" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showRemaining(['%s' + message + '%d', string, number]);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows remaining time (bootstrap)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                fc.integer(),
+                function (message, string, number) {
+                    message = message.join('');
+                    string = string.join('');
+                    const expected = '<div id="remainingtime" role="alert" ' +
+                        'class="alert alert-info"><span ' +
+                        'class="glyphicon glyphicon-fire" aria-hidden="true">' +
+                        '</span> <span>' + string + message + number + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="remainingtime" role="alert" class="hidden ' +
+                        'alert alert-info"><span class="glyphicon ' +
+                        'glyphicon-fire" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showRemaining(['%s' + message + '%d', string, number]);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
     });
     });
 
 
     describe('showLoading', function () {
     describe('showLoading', function () {
-        jsc.property(
-            'shows a loading message (basic)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (message, icon) {
-                message = message.join('');
-                icon = icon.join('');
-                const defaultMessage = 'Loading…';
-                if (message.length === 0) {
-                    message = defaultMessage;
+        it('shows a loading message (basic)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (message, icon) {
+                    message = message.join('');
+                    icon = icon.join('');
+                    const defaultMessage = 'Loading…';
+                    if (message.length === 0) {
+                        message = defaultMessage;
+                    }
+                    const expected = '<div id="loadingindicator" class="">' + message + '</div>';
+                    document.body.innerHTML =
+                        '<div id="loadingindicator" class="hidden">' + defaultMessage + '</div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showLoading(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
                 }
                 }
-                const expected = '<div id="loadingindicator" class="">' + message + '</div>';
-                $('body').html(
-                    '<div id="loadingindicator" class="hidden">' + defaultMessage + '</div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showLoading(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+            ));
+        });
 
 
-        jsc.property(
-            'shows a loading message (bootstrap)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (message, icon) {
-                message = message.join('');
-                icon = icon.join('');
-                const defaultMessage = 'Loading…';
-                if (message.length === 0) {
-                    message = defaultMessage;
+        it('shows a loading message (bootstrap)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (message, icon) {
+                    message = message.join('');
+                    icon = icon.join('');
+                    const defaultMessage = 'Loading…';
+                    if (message.length === 0) {
+                        message = defaultMessage;
+                    }
+                    const expected = '<ul class="nav navbar-nav"><li ' +
+                        'id="loadingindicator" class="navbar-text"><span ' +
+                        'class="glyphicon glyphicon-' + icon +
+                        '" aria-hidden="true"></span> <span>' + message + '</span></li></ul>';
+                    document.body.innerHTML =
+                        '<ul class="nav navbar-nav"><li id="loadingindicator" ' +
+                        'class="navbar-text hidden"><span class="glyphicon ' +
+                        'glyphicon-time" aria-hidden="true"></span> ' +
+                        defaultMessage + '</li></ul>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showLoading(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
                 }
                 }
-                const expected = '<ul class="nav navbar-nav"><li ' +
-                    'id="loadingindicator" class="navbar-text"><span ' +
-                    'class="glyphicon glyphicon-' + icon +
-                    '" aria-hidden="true"></span> <span>' + message + '</span></li></ul>';
-                $('body').html(
-                    '<ul class="nav navbar-nav"><li id="loadingindicator" ' +
-                    'class="navbar-text hidden"><span class="glyphicon ' +
-                    'glyphicon-time" aria-hidden="true"></span> ' +
-                    defaultMessage + '</li></ul>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showLoading(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+            ));
+        });
     });
     });
 
 
     describe('hideLoading', function () {
     describe('hideLoading', function () {
         it(
         it(
             'hides the loading message',
             'hides the loading message',
             function() {
             function() {
-                $('body').html(
+                document.body.innerHTML =
                     '<ul class="nav navbar-nav"><li id="loadingindicator" ' +
                     '<ul class="nav navbar-nav"><li id="loadingindicator" ' +
                     'class="navbar-text"><span class="glyphicon ' +
                     'class="navbar-text"><span class="glyphicon ' +
                     'glyphicon-time" aria-hidden="true"></span> ' +
                     'glyphicon-time" aria-hidden="true"></span> ' +
-                    'Loading…</li></ul>'
-                );
-                $('body').addClass('loading');
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.hideLoading();
-                assert.ok(!$('body').hasClass('loading'));
-                assert.ok($('#loadingindicator').hasClass('hidden'));
+                    'Loading…</li></ul>';
+                document.body.classList.add('loading');
+                PrivateBin.Alert.init();
+                PrivateBin.Alert.hideLoading();
+                assert.ok(!document.body.classList.contains('loading'));
+                assert.ok(document.getElementById('loadingindicator').classList.contains('hidden'));
             }
             }
         );
         );
     });
     });
@@ -316,64 +316,63 @@ describe('Alert', function () {
         it(
         it(
             'hides all messages',
             'hides all messages',
             function() {
             function() {
-                $('body').html(
+                document.body.innerHTML =
                     '<div id="status" role="alert" class="statusmessage ' +
                     '<div id="status" role="alert" class="statusmessage ' +
                     'alert alert-info"><span class="glyphicon ' +
                     'alert alert-info"><span class="glyphicon ' +
                     'glyphicon-info-sign" aria-hidden="true"></span> </div>' +
                     'glyphicon-info-sign" aria-hidden="true"></span> </div>' +
                     '<div id="errormessage" role="alert" class="statusmessage ' +
                     '<div id="errormessage" role="alert" class="statusmessage ' +
                     'alert alert-danger"><span class="glyphicon ' +
                     'alert alert-danger"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.hideMessages();
-                assert.ok($('#status').hasClass('hidden'));
-                assert.ok($('#errormessage').hasClass('hidden'));
+                    'glyphicon-alert" aria-hidden="true"></span> </div>';
+                PrivateBin.Alert.init();
+                PrivateBin.Alert.hideMessages();
+                assert.ok(document.getElementById('status').classList.contains('hidden'));
+                assert.ok(document.getElementById('errormessage').classList.contains('hidden'));
             }
             }
         );
         );
     });
     });
 
 
     describe('setCustomHandler', function () {
     describe('setCustomHandler', function () {
-        jsc.property(
-            'calls a given handler function',
-            'nat 3',
-            jsc.array(common.jscAlnumString()),
-            function (trigger, message) {
-                message = message.join('');
-                let handlerCalled = false,
-                    defaultMessage = 'Loading…',
-                    functions = [
-                        $.PrivateBin.Alert.showStatus,
-                        $.PrivateBin.Alert.showError,
-                        $.PrivateBin.Alert.showRemaining,
-                        $.PrivateBin.Alert.showLoading
-                    ];
-                if (message.length === 0) {
-                    message = defaultMessage;
+        it('calls a given handler function', () => {
+            fc.assert(fc.property(
+                fc.integer({min: 0, max: 3}),
+                fc.array(common.fcAlnumString()),
+                function (trigger, message) {
+                    message = message.join('');
+                    let handlerCalled = false,
+                        defaultMessage = 'Loading…',
+                        functions = [
+                            PrivateBin.Alert.showStatus,
+                            PrivateBin.Alert.showError,
+                            PrivateBin.Alert.showRemaining,
+                            PrivateBin.Alert.showLoading
+                        ];
+                    if (message.length === 0) {
+                        message = defaultMessage;
+                    }
+                    document.body.innerHTML =
+                        '<ul class="nav navbar-nav"><li id="loadingindicator" ' +
+                        'class="navbar-text hidden"><span class="glyphicon ' +
+                        'glyphicon-time" aria-hidden="true"></span> ' +
+                        defaultMessage + '</li></ul>' +
+                        '<div id="remainingtime" role="alert" class="hidden ' +
+                        'alert alert-info"><span class="glyphicon ' +
+                        'glyphicon-fire" aria-hidden="true"></span> </div>' +
+                        '<div id="status" role="alert" class="statusmessage ' +
+                        'alert alert-info"><span class="glyphicon ' +
+                        'glyphicon-info-sign" aria-hidden="true"></span> </div>' +
+                        '<div id="errormessage" role="alert" class="statusmessage ' +
+                        'alert alert-danger"><span class="glyphicon ' +
+                        'glyphicon-alert" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.setCustomHandler(function(id, element) {
+                        handlerCalled = true;
+                        return Math.random() > 0.5 ? true : element;
+                    });
+                    functions[trigger](message);
+                    PrivateBin.Alert.setCustomHandler(null);
+                    return handlerCalled;
                 }
                 }
-                $('body').html(
-                    '<ul class="nav navbar-nav"><li id="loadingindicator" ' +
-                    'class="navbar-text hidden"><span class="glyphicon ' +
-                    'glyphicon-time" aria-hidden="true"></span> ' +
-                    defaultMessage + '</li></ul>' +
-                    '<div id="remainingtime" role="alert" class="hidden ' +
-                    'alert alert-info"><span class="glyphicon ' +
-                    'glyphicon-fire" aria-hidden="true"></span> </div>' +
-                    '<div id="status" role="alert" class="statusmessage ' +
-                    'alert alert-info"><span class="glyphicon ' +
-                    'glyphicon-info-sign" aria-hidden="true"></span> </div>' +
-                    '<div id="errormessage" role="alert" class="statusmessage ' +
-                    'alert alert-danger"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.setCustomHandler(function(id, $element) {
-                    handlerCalled = true;
-                    return jsc.random(0, 1) ? true : $element;
-                });
-                functions[trigger](message);
-                $.PrivateBin.Alert.setCustomHandler(null);
-                return handlerCalled;
-            }
-        );
+            ));
+        });
     });
     });
 });
 });

+ 261 - 142
js/test/AttachmentViewer.js

@@ -1,141 +1,256 @@
 'use strict';
 'use strict';
 const common = require('../common');
 const common = require('../common');
+const bodyTemplate = '<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>' +
+    '<div id="attachment" class="hidden"></div>' +
+    '<div id="templates">' +
+        '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
+            '<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>' +
+            '<a class="alert-link">Download attachment</a>' +
+        '</div>' +
+    '</div>';
+const fc = require('fast-check');
 
 
 describe('AttachmentViewer', function () {
 describe('AttachmentViewer', function () {
-    describe('setAttachment, showAttachment, removeAttachment, hideAttachment, hideAttachmentPreview, hasAttachment, getAttachment & moveAttachmentTo', function () {
+    beforeEach(() => {
+        mockCreateObjectUrl();
+    });
+
+    afterEach(() => {
+        globalThis.cleanup()
+    });
+
+    describe('whole run (setAttachment, showAttachment, removeAttachment, hideAttachment, hideAttachmentPreview, hasAttachment, getAttachment & moveAttachmentTo)', function () {
         this.timeout(30000);
         this.timeout(30000);
 
 
-        jsc.property(
-            'displays & hides data as requested',
-            common.jscMimeTypes(),
-            'string',
-            'string',
-            'string',
-            'string',
-             // eslint-disable-next-line complexity
-            function (mimeType, rawdata, filename, prefix, postfix) {
-                let clean = jsdom(),
-                    data = 'data:' + mimeType + ';base64,' + common.btoa(rawdata),
-                    mimePrefix = mimeType.substring(0, 6),
-                    previewSupported = (
-                        mimePrefix === 'image/' ||
-                        mimePrefix === 'audio/' ||
-                        mimePrefix === 'video/' ||
-                        mimeType.match(/\/pdf/i)
-                    ),
-                    results = [],
-                    result = '';
-                // text node of attachment will truncate at null byte
-                if (filename === '\u0000') {
-                    filename = '';
-                }
-                prefix  = prefix.replace(/%(s|d)/g, '%%');
-                postfix = postfix.replace(/%(s|d)/g, '%%').replace(/<|>/g, '');
-                $('body').html(
-                    '<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>' +
-                    '<div id="attachment" class="hidden"></div>' +
-                    '<div id="templates">' +
-                        '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
-                            '<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>' +
-                            '<a class="alert-link">Download attachment</a>' +
-                        '</div>' +
-                    '</div>'
-                );
-                // mock createObjectURL for jsDOM
-                if (typeof window.URL.createObjectURL === 'undefined') {
-                    Object.defineProperty(
-                        window.URL,
-                        'createObjectURL',
-                        {value: function(blob) {
-                            return 'blob:' + location.origin + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
-                        }}
+        it('displays & hides data as requested', () => {
+            fc.assert(fc.property(
+                common.fcMimeTypes(),
+                fc.string(),
+                fc.string(),
+                fc.string(),
+                fc.string(),
+                // eslint-disable-next-line complexity
+                function (mimeType, rawdata, filename, prefix, postfix) {
+                    let data = 'data:' + mimeType + ';base64,' + common.btoa(rawdata),
+                        mimePrefix = mimeType.substring(0, 6),
+                        previewSupported = (
+                            mimePrefix === 'image/' ||
+                            mimePrefix === 'audio/' ||
+                            mimePrefix === 'video/' ||
+                            mimeType.match(/\/pdf/i)
+                        ) && mimeType.match(/^[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-z0-9][a-z0-9.+-]*[a-z0-9]$/),
+                        results = [],
+                        result = '';
+                    // text node of attachment will truncate at null byte
+                    if (filename === '\u0000') {
+                        filename = '';
+                    }
+                    prefix  = prefix.replace(/%(s|d)/g, '%%');
+                    postfix = postfix.replace(/%(s|d)/g, '%%').replace(/<|>/g, '');
+                    document.body.innerHTML = bodyTemplate;
+                    mockCreateObjectUrl(false);
+                    PrivateBin.AttachmentViewer.init();
+                    PrivateBin.Model.init();
+                    results.push(
+                        !PrivateBin.AttachmentViewer.hasAttachment() &&
+                        document.getElementById('attachment').classList.contains('hidden') &&
+                        document.getElementById('attachment').children.length === 0 &&
+                        document.getElementById('attachmenttemplate').classList.contains('hidden') &&
+                        document.getElementById('attachmentPreview').classList.contains('hidden')
+                    );
+                    global.atob = common.atob;
+                    if (filename.length) {
+                        PrivateBin.AttachmentViewer.setAttachment(data, filename);
+                    } else {
+                        PrivateBin.AttachmentViewer.setAttachment(data);
+                    }
+                    // // beyond this point we will get the blob URL instead of the data
+                    data = window.URL.createObjectURL(data);
+                    const attachment = PrivateBin.AttachmentViewer.getAttachments();
+                    results.push(
+                        PrivateBin.AttachmentViewer.hasAttachment() &&
+                        document.getElementById('attachment').classList.contains('hidden') &&
+                        document.getElementById('attachment').children.length > 0 &&
+                        document.getElementById('attachmentPreview').classList.contains('hidden') &&
+                        attachment[0][0] === data &&
+                        attachment[0][1] === filename
+                    );
+                    PrivateBin.AttachmentViewer.showAttachment();
+                    results.push(
+                        !document.getElementById('attachment').classList.contains('hidden') &&
+                        document.getElementById('attachment').children.length > 0 &&
+                        (previewSupported ? !document.getElementById('attachmentPreview').classList.contains('hidden') : document.getElementById('attachmentPreview').classList.contains('hidden'))
+                    );
+                    PrivateBin.AttachmentViewer.hideAttachment();
+                    results.push(
+                        document.getElementById('attachment').classList.contains('hidden') &&
+                        (previewSupported ? !document.getElementById('attachmentPreview').classList.contains('hidden') : document.getElementById('attachmentPreview').classList.contains('hidden'))
+                    );
+                    if (previewSupported) {
+                        PrivateBin.AttachmentViewer.hideAttachmentPreview();
+                        results.push(document.getElementById('attachmentPreview').classList.contains('hidden'));
+                    }
+                    PrivateBin.AttachmentViewer.showAttachment();
+                    results.push(
+                        !document.getElementById('attachment').classList.contains('hidden') &&
+                        (previewSupported ? !document.getElementById('attachmentPreview').classList.contains('hidden') : document.getElementById('attachmentPreview').classList.contains('hidden'))
+                    );
+                    let element = document.createElement('div');
+                    PrivateBin.AttachmentViewer.moveAttachmentTo(element, attachment[0], prefix + '%s' + postfix);
+                    // messageIDs with links get a relaxed treatment
+                    if (prefix.indexOf('<a') === -1 && postfix.indexOf('<a') === -1) {
+                        const tempTA = document.createElement('textarea');
+                        tempTA.textContent = (prefix + filename + postfix);
+                        result = tempTA.textContent;
+                    } else {
+                        result = DOMPurify.sanitize(
+                            prefix + PrivateBin.Helper.htmlEntities(filename) + postfix, {
+                                ALLOWED_TAGS: ['a', 'i', 'span'],
+                                ALLOWED_ATTR: ['href', 'id']
+                            }
+                        );
+                    }
+                    if (filename.length) {
+                        results.push(
+                            element.querySelector('a').href === data &&
+                            element.querySelector('a').getAttribute('download') === filename &&
+                            element.querySelector('a').textContent === result
+                        );
+                    } else {
+                        results.push(element.querySelector('a').href === data);
+                    }
+                    PrivateBin.AttachmentViewer.removeAttachment();
+                    results.push(
+                        document.getElementById('attachment').classList.contains('hidden') &&
+                        document.getElementById('attachment').children.length === 0 &&
+                        document.getElementById('attachmentPreview').classList.contains('hidden')
                     );
                     );
+                    return results.every(element => element);
                 }
                 }
-                $.PrivateBin.AttachmentViewer.init();
-                $.PrivateBin.Model.init();
-                results.push(
-                    !$.PrivateBin.AttachmentViewer.hasAttachment() &&
-                    $('#attachment').hasClass('hidden') &&
-                    $('#attachment').children().length === 0 &&
-                    $('#attachmenttemplate').hasClass('hidden') &&
-                    $('#attachmentPreview').hasClass('hidden')
-                );
+            ));
+        });
+
+        it(
+            'sanitizes file names',
+            function() {
+                document.body.innerHTML = bodyTemplate;
+                PrivateBin.AttachmentViewer.init();
+                PrivateBin.Model.init();
                 global.atob = common.atob;
                 global.atob = common.atob;
-                if (filename.length) {
-                    $.PrivateBin.AttachmentViewer.setAttachment(data, filename);
-                } else {
-                    $.PrivateBin.AttachmentViewer.setAttachment(data);
+
+                const maliciousFileNames = [
+                    '<script>alert("☹️");//<a',
+                    '"><meta http-equiv="refresh" content="0;url=http://example.com/">.txt'
+                ];
+                for (const filename of maliciousFileNames) {
+                    PrivateBin.AttachmentViewer.setAttachment('data:;base64,', filename);
+                    assert.ok(!document.body.innerHTML.includes(filename));
+                    PrivateBin.AttachmentViewer.removeAttachment();
                 }
                 }
-                // // beyond this point we will get the blob URL instead of the data
-                data = window.URL.createObjectURL(data);
-                const attachment = $.PrivateBin.AttachmentViewer.getAttachments();
-                results.push(
-                    $.PrivateBin.AttachmentViewer.hasAttachment() &&
-                    $('#attachment').hasClass('hidden') &&
-                    $('#attachment').children().length > 0 &&
-                    $('#attachmentPreview').hasClass('hidden') &&
-                    attachment[0][0] === data &&
-                    attachment[0][1] === filename
-                );
-                $.PrivateBin.AttachmentViewer.showAttachment();
-                results.push(
-                    !$('#attachment').hasClass('hidden') &&
-                    $('#attachment').children().length > 0 &&
-                    (previewSupported ? !$('#attachmentPreview').hasClass('hidden') : $('#attachmentPreview').hasClass('hidden'))
-                );
-                $.PrivateBin.AttachmentViewer.hideAttachment();
-                results.push(
-                    $('#attachment').hasClass('hidden') &&
-                    (previewSupported ? !$('#attachmentPreview').hasClass('hidden') : $('#attachmentPreview').hasClass('hidden'))
-                );
-                if (previewSupported) {
-                    $.PrivateBin.AttachmentViewer.hideAttachmentPreview();
-                    results.push($('#attachmentPreview').hasClass('hidden'));
+            }
+        );
+
+        it(
+            'sanitizes MIME types in attachments',
+            function() {
+                document.body.innerHTML = bodyTemplate;
+                PrivateBin.AttachmentViewer.init();
+                PrivateBin.Model.init();
+                global.atob = common.atob;
+
+                const maliciousMimeTypes = [
+                    // PDF bypasses
+                    'application/x-pdf',    // legacy, we don't need to support this
+                    'text/html /pdf',       // trips up Firefox and Chromium
+                    'text/html(/pdf',       // Chromium, see: https://chromium.googlesource.com/chromium/src/+/refs/tags/152.0.7949.0/net/base/mime_util.cc#521
+
+                    // SVG bypass
+                    'text/html svg',
+                    'text/html(svg',
+
+                    // invalid bytes after string
+                    'image/png\x01'
+                ];
+                for (const mimeType of maliciousMimeTypes) {
+                    assert.ok(!PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'does not treat as safe MIME type: '+ mimeType);
+                    PrivateBin.AttachmentViewer.setAttachment('data:' + mimeType + ';base64,', 'example file name');
+                    assert.ok(!document.body.innerHTML.includes(mimeType), 'does not allow MIME type: ' + mimeType);
+                    assert.ok(!document.body.innerHTML.includes(mimeType.toLowerCase()), 'does not allow lower cased MIME type: ' + mimeType);
+                    assert.ok(!document.body.innerHTML.includes('<img'), 'does not allow image MIME type: ' + mimeType);
+                    PrivateBin.AttachmentViewer.removeAttachment();
                 }
                 }
-                $.PrivateBin.AttachmentViewer.showAttachment();
-                results.push(
-                    !$('#attachment').hasClass('hidden') &&
-                    (previewSupported ? !$('#attachmentPreview').hasClass('hidden') : $('#attachmentPreview').hasClass('hidden'))
-                );
-                let element = $('<div>');
-                $.PrivateBin.AttachmentViewer.moveAttachmentTo(element, attachment[0], prefix + '%s' + postfix);
-                // messageIDs with links get a relaxed treatment
-                if (prefix.indexOf('<a') === -1 && postfix.indexOf('<a') === -1) {
-                    result = $('<textarea>').text((prefix + filename + postfix)).text();
-                } else {
-                    result = DOMPurify.sanitize(
-                        prefix + $.PrivateBin.Helper.htmlEntities(filename) + postfix, {
-                            ALLOWED_TAGS: ['a', 'i', 'span'],
-                            ALLOWED_ATTR: ['href', 'id']
-                        }
-                    );
+            }
+        );
+
+        it(
+            'supports safe MIME types in attachments',
+            function() {
+                document.body.innerHTML = bodyTemplate;
+                PrivateBin.AttachmentViewer.init();
+                PrivateBin.Model.init();
+                global.atob = common.atob;
+
+                const supportedSafeMimeTypes = [
+                    'text/plain',
+                    'image/png',
+                    'image/jpeg'
+                ];
+                for (const mimeType of supportedSafeMimeTypes) {
+                    assert.ok(PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'treats as safe MIME type: '+ mimeType);
                 }
                 }
-                if (filename.length) {
-                    results.push(
-                        element.find('a')[0].href === data &&
-                        element.find('a')[0].getAttribute('download') === filename &&
-                        element.find('a')[0].text === result
-                    );
-                } else {
-                    results.push(element.find('a')[0].href === data);
+            }
+        );
+
+        it(
+            'supports safe MIME type previews in attachments',
+            function() {
+                document.body.innerHTML = bodyTemplate;
+                PrivateBin.AttachmentViewer.init();
+                PrivateBin.Model.init();
+                global.atob = common.atob;
+
+                const supportedPreviewMimeTypes = [
+                    'application/pdf',
+                    'audio/wav',
+                    'video/avi'
+                ];
+                for (const mimeType of supportedPreviewMimeTypes) {
+                    assert.ok(PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'treats as safe preview MIME type: '+ mimeType);
+                    PrivateBin.AttachmentViewer.setAttachment('data:' + mimeType + ';base64,', 'example file name');
+                    assert.ok(document.body.innerHTML.includes(mimeType), 'allows MIME type: ' + mimeType);
+                    PrivateBin.AttachmentViewer.removeAttachment();
                 }
                 }
-                $.PrivateBin.AttachmentViewer.removeAttachment();
-                results.push(
-                    $('#attachment').hasClass('hidden') &&
-                    $('#attachment').children().length === 0 &&
-                    $('#attachmentPreview').hasClass('hidden')
-                );
-                clean();
-                return results.every(element => element);
             }
             }
         );
         );
 
 
         it(
         it(
-            'sanitizes file names in attachments',
+            'sanitize potentially unsafe SVG previews',
+            function() {
+                document.body.innerHTML = bodyTemplate;
+                PrivateBin.AttachmentViewer.init();
+                PrivateBin.Model.init();
+                global.atob = common.atob;
+
+                const svgMimeTypes = [
+                    'image/svg+xml',
+                    'image/SVG+xml',
+                    'image/SVG',
+                    'image/sVg'
+                ];
+                for (const mimeType of svgMimeTypes) {
+                    assert.ok(!PrivateBin.AttachmentViewer.isSafeMimeType(mimeType), 'treats as unsafe MIME type: '+ mimeType);
+                    PrivateBin.AttachmentViewer.setAttachment('data:' + mimeType + ';base64,', 'example file name');
+                    assert.ok(document.body.innerHTML.includes('image/svg+xml'), 'allows sanitized MIME type: ' + mimeType);
+                    PrivateBin.AttachmentViewer.removeAttachment();
+                }
+            }
+        );
+    });
+
+    describe('showAttachment()', function () {
+        it('displays attachment even when attachmentPreview element is missing',
             function() {
             function() {
-                const clean = jsdom();
-                $('body').html(
-                    '<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>' +
+                document.body.innerHTML = (
                     '<div id="attachment" class="hidden"></div>' +
                     '<div id="attachment" class="hidden"></div>' +
                     '<div id="templates">' +
                     '<div id="templates">' +
                         '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
                         '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
@@ -144,31 +259,35 @@ describe('AttachmentViewer', function () {
                         '</div>' +
                         '</div>' +
                     '</div>'
                     '</div>'
                 );
                 );
-                // mock createObjectURL for jsDOM
-                if (typeof window.URL.createObjectURL === 'undefined') {
-                    Object.defineProperty(
-                        window.URL,
-                        'createObjectURL',
-                        {value: function(blob) {
-                            return 'blob:' + location.origin + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
-                        }}
-                    );
-                }
-                $.PrivateBin.AttachmentViewer.init();
-                $.PrivateBin.Model.init();
+                // Note: attachmentPreview element is intentionally NOT created
+
+                PrivateBin.AttachmentViewer.init();
+                PrivateBin.Model.init();
                 global.atob = common.atob;
                 global.atob = common.atob;
 
 
-                const maliciousFileNames = [
-                    '<script>alert("☹️");//<a',
-                    '"><meta http-equiv="refresh" content="0;url=http://example.com/">.txt'
-                ];
-                for (const filename of maliciousFileNames) {
-                    $.PrivateBin.AttachmentViewer.setAttachment('data:;base64,', filename);
-                    assert.ok(!$('body').html().includes(filename));
+                // Set attachment without preview element
+                PrivateBin.AttachmentViewer.setAttachment('data:text/plain;base64,', 'test.txt');
+
+                // Show attachment should work even without attachmentPreview
+                PrivateBin.AttachmentViewer.showAttachment();
+
+                const attachment = document.getElementById('attachment');
+                assert.ok(!attachment.classList.contains('hidden'), 'Attachment should be visible');
+                assert.ok(attachment.children.length > 0, 'Attachment should have content');
+            }
+        )
+    });
+
+    function mockCreateObjectUrl(includeType = true) {
+        Object.defineProperty(
+            window.URL,
+            'createObjectURL',
+            {
+                configurable: true,
+                value: function (blob) {
+                    return 'blob:' + (includeType ? blob.type : location.origin) + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
                 }
                 }
-                clean();
             }
             }
         );
         );
-
-    });
+    }
 });
 });

+ 68 - 63
js/test/Check.js

@@ -1,18 +1,19 @@
 'use strict';
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 /* global Legacy, WebCrypto */
 /* global Legacy, WebCrypto */
 
 
 describe('Check', function () {
 describe('Check', function () {
     describe('init', function () {
     describe('init', function () {
         this.timeout(30000);
         this.timeout(30000);
 
 
-        it('returns false and shows error, if a bot UA is detected', function () {
-            jsc.assert(jsc.forall(
-                'string',
-                jsc.elements(['Bot', 'bot']),
-                'string',
+        it('returns false and shows error, if a bot UA is detected', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.constantFrom('Bot', 'bot'),
+                fc.string(),
                 function (prefix, botBit, suffix) {
                 function (prefix, botBit, suffix) {
-                    const clean = jsdom(
+                    const clean = globalThis.cleanup(
                         '<html><body><div id="errormessage" class="hidden"></div>' +
                         '<html><body><div id="errormessage" class="hidden"></div>' +
                         '</body></html>', {
                         '</body></html>', {
                             'userAgent': prefix + botBit + suffix
                             'userAgent': prefix + botBit + suffix
@@ -24,66 +25,70 @@ describe('Check', function () {
                     return result;
                     return result;
                 }
                 }
             ),
             ),
-            {tests: 10});
+            {numRuns: 10});
         });
         });
 
 
-        jsc.property(
-            'shows error, if no webcrypto is detected',
-            'bool',
-            jsc.elements(['localhost', '127.0.0.1', '[::1]', '']),
-            jsc.nearray(common.jscA2zString()),
-            jsc.elements(['.onion', '.i2p', '']),
-            function (secureProtocol, localhost, domain, tld) {
-                const isDomain = localhost === '',
-                      isSecureContext = secureProtocol || !isDomain || tld.length > 0,
-                      clean = jsdom(
-                          '<html><body><div id="errormessage" class="hidden"></div>' +
-                          '<div id="oldnotice" class="hidden"></div>' +
-                          '<div id="insecurecontextnotice" class="hidden"></div></body></html>',
-                          {
-                              'url': (secureProtocol ? 'https' : 'http' ) + '://' +
-                                     (isDomain ? domain.join('') + tld : localhost) + '/'
-                          }
-                      );
-                Legacy.Check.init();
-                const result1 = Legacy.Check.getInit() && !Legacy.Check.getStatus(),
-                      result2 = isSecureContext === (document.getElementById('insecurecontextnotice').className === 'hidden'),
-                      result3 = (document.getElementById('oldnotice').className !== 'hidden');
-                clean();
-                if (result1 && result2 && result3) {
-                    return true;
+        it('shows error, if no webcrypto is detected', () => {
+            fc.assert(fc.property(
+                fc.boolean(),
+                fc.constantFrom('localhost', '127.0.0.1', '[::1]', ''),
+                fc.array(common.fcA2zString(), {minLength: 1}),
+                fc.constantFrom('.onion', '.i2p', ''),
+                function (secureProtocol, localhost, domain, tld) {
+                    const isDomain = localhost === '',
+                          isSecureContext = secureProtocol || !isDomain || tld.length > 0,
+                          clean = globalThis.cleanup(
+                              '<html><body><div id="errormessage" class="hidden"></div>' +
+                              '<div id="oldnotice" class="hidden"></div>' +
+                              '<div id="insecurecontextnotice" class="hidden"></div></body></html>',
+                              {
+                                  'url': (secureProtocol ? 'https' : 'http' ) + '://' +
+                                         (isDomain ? domain.join('') + tld : localhost) + '/'
+                              }
+                          );
+                    Legacy.Check.init();
+                    const result1 = Legacy.Check.getInit() && !Legacy.Check.getStatus(),
+                          result2 = isSecureContext === (document.getElementById('insecurecontextnotice').className === 'hidden'),
+                          result3 = (document.getElementById('oldnotice').className !== 'hidden');
+                    clean();
+                    if (result1 && result2 && result3) {
+                        return true;
+                    }
+                    console.log(result1, result2, result3);
+                    return false;
                 }
                 }
-                console.log(result1, result2, result3);
-                return false;
-            }
-        );
+            ));
+        });
 
 
-        jsc.property(
-            'shows error, if HTTP only site is detected',
-            'bool',
-            jsc.nearray(common.jscA2zString()),
-            function (secureProtocol, domain) {
-                const clean = jsdom(
-                          '<html><body><div id="httpnotice" class="hidden"></div>' +
-                          '</body></html>',
-                          {
-                              'url': (secureProtocol ? 'https' : 'http' ) + '://' + domain.join('') + '/'
-                          }
-                      );
-                Object.defineProperty(window, 'crypto', {
-                    value: new WebCrypto(),
-                    writeable: false
-                });
-                Legacy.Check.init();
-                const result1 = Legacy.Check.getInit() && Legacy.Check.getStatus(),
-                      result2 = secureProtocol === (document.getElementById('httpnotice').className === 'hidden');
-                clean();
-                if (result1 && result2) {
-                    return true;
+        it('shows error, if HTTP only site is detected', () => {
+            fc.assert(fc.property(
+                fc.boolean(),
+                fc.array(common.fcA2zString(), {minLength: 1}),
+                function (secureProtocol, domain) {
+                    const clean = globalThis.cleanup(
+                              '<html><body><div id="httpnotice" class="hidden"></div>' +
+                              '</body></html>',
+                              {
+                                  'url': (secureProtocol ? 'https' : 'http' ) + '://' + domain.join('') + '/'
+                              }
+                          );
+                    Object.defineProperty(window, 'crypto', {
+                        value: new WebCrypto(),
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
+                    });
+                    Legacy.Check.init();
+                    const result1 = Legacy.Check.getInit() && Legacy.Check.getStatus(),
+                          result2 = secureProtocol === (document.getElementById('httpnotice').className === 'hidden');
+                    clean();
+                    if (result1 && result2) {
+                        return true;
+                    }
+                    console.log(result1, result2);
+                    return false;
                 }
                 }
-                console.log(result1, result2);
-                return false;
-            }
-        );
+            ));
+        });
     });
     });
 });
 });

+ 142 - 114
js/test/CopyToClipboard.js

@@ -1,141 +1,169 @@
 'use strict';
 'use strict';
 const common = require('../common');
 const common = require('../common');
+const fc = require('fast-check');
 
 
-describe('CopyToClipboard', function() {
+describe('CopyToClipboard', function () {
     this.timeout(30000);
     this.timeout(30000);
 
 
-    describe ('Copy document to clipboard', function () {
-        jsc.property('Copy with button click',
-            common.jscFormats(),
-            'nestring',
-            async function (format, text) {
-                var clean = jsdom();
-                common.enableClipboard();
-
-                $('body').html(
-                    '<div id="placeholder"></div>' +
-					'<div id="attachmentPreview" class="hidden"></div>' +
-                    '<h5 id="copyShortcutHint" class="hidden">' +
-						'<small id="copyShortcutHintText"></small>' +
-						'<button type="button" id="copyShortcutHintBtn"></button>' +
-					'</h5>' +
-                    '<div id="prettymessage" class="hidden">' +
-						'<pre id="prettyprint"></pre>' +
-					'</div>'
-                );
-
-                $.PrivateBin.PasteViewer.init();
-                $.PrivateBin.PasteViewer.setFormat(format);
-                $.PrivateBin.PasteViewer.setText(text);
-                $.PrivateBin.PasteViewer.run();
-
-                $.PrivateBin.CopyToClipboard.init();
-
-                $('#copyShortcutHintBtn').trigger('click');
-
-                const savedToClipboardText = await navigator.clipboard.readText();
-
-                clean();
+    afterEach(() => {
+        globalThis.cleanup();
+    });
 
 
-                return text === savedToClipboardText;
-            }
-        );
+    describe('Copy document to clipboard', function () {
+        it('Copy with button click', async function () {
+            await fc.assert(fc.asyncProperty(
+                common.fcFormats(),
+                fc.string({minLength: 1}),
+                async function (format, text) {
+                    common.enableClipboard();
+
+                    document.body.innerHTML = (
+                        '<div id="status" class="hidden"></div>' +
+                        '<div id="errormessage" class="hidden"></div>' +
+                        '<div id="placeholder" class="hidden">+++ no document text ' +
+                        '+++</div><div id="prettymessage" class="hidden">' +
+                        '<button type="button" id="prettyMessageCopyBtn"><svg id="copyIcon"></svg>' +
+                        '<svg id="copySuccessIcon"></svg></button><pre ' +
+                        'id="prettyprint" class="prettyprint linenums:1"></pre>' +
+                        '</div><div id="plaintext" class="hidden"></div>' +
+                        '<h5 id="copyShortcutHint" class="hidden">' +
+                        '<button type="button" id="copyShortcutHintBtn"></button>' +
+                        '</h5>'
+                    );
+
+                    PrivateBin.Alert.init();
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText(text);
+                    PrivateBin.PasteViewer.run();
+
+                    PrivateBin.CopyToClipboard.init();
+
+                    document.getElementById('copyShortcutHintBtn').click();
+
+                    const savedToClipboardText = await navigator.clipboard.readText();
+
+                    return text === savedToClipboardText;
+                }
+            ), { numRuns: 20 });
+        });
 
 
         /**
         /**
-         * Unfortunately in JSVerify impossible to check if copy with shortcut when user selected some text on the page
+         * Unfortunately, in JSVerify impossible to check if copy with shortcut when user selected some text on the page
          * (the copy document to clipboard should not work in this case) due to lacking window.getSelection() in jsdom.
          * (the copy document to clipboard should not work in this case) due to lacking window.getSelection() in jsdom.
          */
          */
-        jsc.property('Copy with keyboard shortcut',
-            common.jscFormats(),
-            'nestring',
-            async function (format, text) {
-                var clean = jsdom();
-                common.enableClipboard();
-
-                $('body').html(
-                    '<div id="placeholder"></div>' +
-					'<div id="attachmentPreview" class="hidden"></div>' +
-                    '<h5 id="copyShortcutHint" class="hidden">' +
-						'<small id="copyShortcutHintText"></small>' +
-						'<button type="button" id="copyShortcutHintBtn"></button>' +
-					'</h5>' +
-                    '<div id="prettymessage" class="hidden">' +
-						'<pre id="prettyprint"></pre>' +
-					'</div>'
-                );
-
-                $.PrivateBin.PasteViewer.init();
-                $.PrivateBin.PasteViewer.setFormat(format);
-                $.PrivateBin.PasteViewer.setText(text);
-                $.PrivateBin.PasteViewer.run();
-
-                $.PrivateBin.CopyToClipboard.init();
+        it('Copy with keyboard shortcut', async function () {
+            await fc.assert(fc.asyncProperty(
+                common.fcFormats(),
+                fc.string({minLength: 1}),
+                async function (format, text) {
+                    common.enableClipboard();
+
+                    document.body.innerHTML = (
+                        '<div id="status" class="hidden"></div>' +
+                        '<div id="errormessage" class="hidden"></div>' +
+                        '<div id="placeholder">+++ no document text ' +
+                        '+++</div><div id="prettymessage" class="hidden">' +
+                        '<button type="button" id="prettyMessageCopyBtn"><svg id="copyIcon"></svg>' +
+                        '<svg id="copySuccessIcon"></svg></button><pre ' +
+                        'id="prettyprint" class="prettyprint linenums:1"></pre>' +
+                        '</div><div id="plaintext" class="hidden"></div>' +
+                        '<h5 id="copyShortcutHint" class="hidden">' +
+                        '<button type="button" id="copyShortcutHintBtn"></button>' +
+                        '</h5>'
+                    );
+
+                    PrivateBin.Alert.init();
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText(text);
+                    PrivateBin.PasteViewer.run();
+
+                    PrivateBin.CopyToClipboard.init();
+
+                    document.body.dispatchEvent(getClipboardEvent());
+
+                    const copiedTextWithoutSelectedText = await navigator.clipboard.readText();
+
+                    return copiedTextWithoutSelectedText === text;
+                }
+            ), { numRuns: 20 });
+        });
 
 
-                $('body').trigger('copy');
-
-                const copiedTextWithoutSelectedText = await navigator.clipboard.readText();
-
-                clean();
-
-                return copiedTextWithoutSelectedText === text;
+        /**
+         * ClipboardEvent is not supported in jsdom yet, so this creates a mock event to trigger the copy event listener.
+         *
+         * See https://github.com/jsdom/jsdom/issues/1568
+         *
+         * @returns {ClipboardEvent}
+         */
+        function getClipboardEvent() {
+            /** {@type ClipboardEvent} */
+            const clipboardEvent = new Event('copy', {
+                bubbles: true,
+                cancelable: true,
+                composed: true
+            });
+            clipboardEvent['clipboardData'] = {
+                getData: function () {
+                    return '';
+                }
             }
             }
-        );
-    });
-
-
-    jsc.property('Copy link to clipboard',
-        'nestring',
-        async function (text) {
-            var clean = jsdom();
-            common.enableClipboard();
-
-            $('body').html('<button id="copyLink"></button>');
-
-            $.PrivateBin.CopyToClipboard.init();
-            $.PrivateBin.CopyToClipboard.setUrl(text);
-
-            $('#copyLink').trigger('click');
-
-            const copiedText = await navigator.clipboard.readText();
-
-            clean();
-
-            return text === copiedText;
+            return clipboardEvent;
         }
         }
-    );
+    });
 
 
 
 
-    describe('Keyboard shortcut hint', function () {
-        jsc.property('Show hint', function () {
-                var clean = jsdom();
+    it('Copy link to clipboard', async function () {
+        await fc.assert(fc.asyncProperty(fc.string(),
+            async function (text) {
+                common.enableClipboard();
 
 
-                $('body').html('<h5 id="copyShortcutHint" class="hidden"></h5>');
+                document.body.innerHTML = (
+                    '<div id="status" class="hidden"></div>' +
+                    '<div id="errormessage" class="hidden"></div>' +
+                    '<button id="copyLink"></button>'
+                );
 
 
-                $.PrivateBin.CopyToClipboard.init();
-                $.PrivateBin.CopyToClipboard.showKeyboardShortcutHint();
+                PrivateBin.Alert.init();
+                PrivateBin.CopyToClipboard.init();
+                PrivateBin.CopyToClipboard.setUrl(text);
 
 
-                const hasHidden = $('#copyShortcutHint').hasClass('hidden');
+                document.getElementById('copyLink').click();
 
 
-                clean();
+                const copiedText = await navigator.clipboard.readText();
 
 
-                return !hasHidden;
-            }
+                return text === copiedText;
+            })
         );
         );
+    });
 
 
-        jsc.property('Hide hint', function () {
-                var clean = jsdom();
-
-                $('body').html('<h5 id="copyShortcutHint"></h5>');
-
-                $.PrivateBin.CopyToClipboard.init();
-                $.PrivateBin.CopyToClipboard.hideKeyboardShortcutHint();
+    describe('Keyboard shortcut hint', function () {
+        it('shows hint', () => {
+            fc.assert(fc.property(fc.string(),
+                function () {
+                    document.body.innerHTML = '<h5 id="copyShortcutHint" class="hidden"></h5>';
+
+                    PrivateBin.CopyToClipboard.init();
+                    PrivateBin.CopyToClipboard.showKeyboardShortcutHint();
+
+                    return !document.getElementById('copyShortcutHint').classList.contains('hidden');
+                }
+            ));
+        });
+    });
 
 
-                const hasHidden = $('#copyShortcutHint').hasClass('hidden');
+    it('Hide hint', () => {
+        fc.assert(fc.property(
+            fc.string({minLength: 1}),
+            function () {
+                document.body.innerHTML = '<h5 id="copyShortcutHint"></h5>';
 
 
-                clean();
+                PrivateBin.CopyToClipboard.init();
+                PrivateBin.CopyToClipboard.hideKeyboardShortcutHint();
 
 
-                return hasHidden;
+                return document.getElementById('copyShortcutHint').classList.contains('hidden');
             }
             }
-        );
+        ));
     });
     });
-});
+});

+ 46 - 34
js/test/CryptTool.js

@@ -1,5 +1,7 @@
 'use strict';
 'use strict';
 const common = require('../common');
 const common = require('../common');
+const fc = require('fast-check');
+const fs = require('fs');
 
 
 describe('CryptTool', function () {
 describe('CryptTool', function () {
     describe('cipher & decipher', function () {
     describe('cipher & decipher', function () {
@@ -9,26 +11,28 @@ describe('CryptTool', function () {
         });
         });
 
 
         this.timeout(30000);
         this.timeout(30000);
-        it('can en- and decrypt any message', function () {
-            jsc.assert(jsc.forall(
-                'string',
-                'string',
-                'string',
+        it('can en- and decrypt any message', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.string(),
+                fc.string(),
+                fc.string(),
                 async function (key, password, message) {
                 async function (key, password, message) {
-                    const clean = jsdom();
+                    const clean = globalThis.cleanup();
                     // ensure zlib is getting loaded
                     // ensure zlib is getting loaded
-                    $.PrivateBin.Controller.initZ();
+                    PrivateBin.Controller.initZlib();
                     Object.defineProperty(window, 'crypto', {
                     Object.defineProperty(window, 'crypto', {
                         value: new WebCrypto(),
                         value: new WebCrypto(),
-                        writeable: false
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
                     });
                     });
                     global.atob = common.atob;
                     global.atob = common.atob;
                     global.btoa = common.btoa;
                     global.btoa = common.btoa;
                     message = message.trim();
                     message = message.trim();
-                    const cipherMessage = await $.PrivateBin.CryptTool.cipher(
+                    const cipherMessage = await PrivateBin.CryptTool.cipher(
                             key, password, message, []
                             key, password, message, []
                         ),
                         ),
-                        plaintext = await $.PrivateBin.CryptTool.decipher(
+                        plaintext = await PrivateBin.CryptTool.decipher(
                             key, password, cipherMessage
                             key, password, cipherMessage
                         );
                         );
                     clean();
                     clean();
@@ -37,24 +41,26 @@ describe('CryptTool', function () {
                     return result;
                     return result;
                 }
                 }
             ),
             ),
-            {tests: 3});
+            {numRuns: 3});
         });
         });
 
 
         it('does not truncate messages', async function () {
         it('does not truncate messages', async function () {
             const message = fs.readFileSync('test/compression-sample.txt', 'ascii').trim(),
             const message = fs.readFileSync('test/compression-sample.txt', 'ascii').trim(),
-                clean = jsdom();
+                clean = globalThis.cleanup();
             Object.defineProperty(window, 'crypto', {
             Object.defineProperty(window, 'crypto', {
                 value: new WebCrypto(),
                 value: new WebCrypto(),
-                writeable: false
+                configurable: true,
+                enumerable: true,
+                writable: false
             });
             });
             // ensure zlib is getting loaded
             // ensure zlib is getting loaded
-            $.PrivateBin.Controller.initZ();
+            PrivateBin.Controller.initZlib();
             global.atob = common.atob;
             global.atob = common.atob;
             global.btoa = common.btoa;
             global.btoa = common.btoa;
-            const cipherMessage = await $.PrivateBin.CryptTool.cipher(
+            const cipherMessage = await PrivateBin.CryptTool.cipher(
                     'foo', 'bar', message, []
                     'foo', 'bar', message, []
                 ),
                 ),
-                plaintext = await $.PrivateBin.CryptTool.decipher(
+                plaintext = await PrivateBin.CryptTool.decipher(
                     'foo', 'bar', cipherMessage
                     'foo', 'bar', cipherMessage
                 );
                 );
             clean();
             clean();
@@ -64,10 +70,10 @@ describe('CryptTool', function () {
             assert.strictEqual(message, plaintext);
             assert.strictEqual(message, plaintext);
         });
         });
 
 
-        it('can en- and decrypt a particular message (#260)', function () {
-            jsc.assert(jsc.forall(
-                'string',
-                'string',
+        it('can en- and decrypt a particular message (#260)', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.string(),
+                fc.string(),
                 async function (key, password) {
                 async function (key, password) {
                     const message = `
                     const message = `
 1 subgoal
 1 subgoal
@@ -91,17 +97,21 @@ isWhile : interp (while expr sBody) (MemElem mem) =
 ======================== ( 1 / 1 )
 ======================== ( 1 / 1 )
 conseq_or_bottom inv (interp (nth_iterate sBody n) (MemElem mem))
 conseq_or_bottom inv (interp (nth_iterate sBody n) (MemElem mem))
 `;
 `;
-                    const clean = jsdom();
+                    const clean = globalThis.cleanup();
                     // ensure zlib is getting loaded
                     // ensure zlib is getting loaded
-                    $.PrivateBin.Controller.initZ();
+                    PrivateBin.Controller.initZlib();
                     Object.defineProperty(window, 'crypto', {
                     Object.defineProperty(window, 'crypto', {
                         value: new WebCrypto(),
                         value: new WebCrypto(),
-                        writeable: false
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
                     });
                     });
-                    const cipherMessage = await $.PrivateBin.CryptTool.cipher(
+                    global.atob = common.atob;
+                    global.btoa = common.btoa;
+                    const cipherMessage = await PrivateBin.CryptTool.cipher(
                             key, password, message, []
                             key, password, message, []
                         ),
                         ),
-                        plaintext = await $.PrivateBin.CryptTool.decipher(
+                        plaintext = await PrivateBin.CryptTool.decipher(
                                 key, password, cipherMessage
                                 key, password, cipherMessage
                         );
                         );
                     clean();
                     clean();
@@ -110,7 +120,7 @@ conseq_or_bottom inv (interp (nth_iterate sBody n) (MemElem mem))
                     return result;
                     return result;
                 }
                 }
             ),
             ),
-            {tests: 3});
+            {numRuns: 3});
         });
         });
     });
     });
 
 
@@ -119,23 +129,25 @@ conseq_or_bottom inv (interp (nth_iterate sBody n) (MemElem mem))
         let keys = [];
         let keys = [];
 
 
         // the parameter is used to ensure the test is run more then one time
         // the parameter is used to ensure the test is run more then one time
-        it('returns random, non-empty keys', function () {
-            jsc.assert(jsc.forall(
-                'integer',
-                function(counter) {
-                    const clean = jsdom();
+        it('returns random, non-empty keys', () => {
+            fc.assert(fc.property(
+                fc.integer(),
+                function() {
+                    const clean = globalThis.cleanup();
                     Object.defineProperty(window, 'crypto', {
                     Object.defineProperty(window, 'crypto', {
                         value: new WebCrypto(),
                         value: new WebCrypto(),
-                        writeable: false
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
                     });
                     });
-                    const key = $.PrivateBin.CryptTool.getSymmetricKey(),
+                    const key = PrivateBin.CryptTool.getSymmetricKey(),
                         result = (key !== '' && keys.indexOf(key) === -1);
                         result = (key !== '' && keys.indexOf(key) === -1);
                     keys.push(key);
                     keys.push(key);
                     clean();
                     clean();
                     return result;
                     return result;
                 }
                 }
             ),
             ),
-            {tests: 10});
+            {numRuns: 10});
         });
         });
     });
     });
 });
 });

+ 118 - 97
js/test/DiscussionViewer.js

@@ -1,112 +1,133 @@
 'use strict';
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 
 describe('DiscussionViewer', function () {
 describe('DiscussionViewer', function () {
     describe('handleNotification, prepareNewDiscussion, addComment, finishDiscussion, getReplyMessage, getReplyNickname, getReplyCommentId & highlightComment', function () {
     describe('handleNotification, prepareNewDiscussion, addComment, finishDiscussion, getReplyMessage, getReplyNickname, getReplyCommentId & highlightComment', function () {
         this.timeout(30000);
         this.timeout(30000);
 
 
-        jsc.property(
-            'displays & hides comments as requested',
-            jsc.array(
-                jsc.record({
-                    idArray: jsc.nearray(common.jscAlnumString()),
-                    parentidArray: jsc.nearray(common.jscAlnumString()),
-                    data: jsc.string,
-                    meta: jsc.record({
-                        nickname: jsc.string,
-                        postdate: jsc.nat,
-                        vizhash: jsc.string
+        it('displays & hides comments as requested', () => {
+            fc.assert(fc.property(
+                fc.array(
+                    fc.record({
+                        idArray: fc.array(common.fcAlnumString(), {minLength: 1}),
+                        parentidArray: fc.array(common.fcAlnumString(), {minLength: 1}),
+                        data: fc.string(),
+                        meta: fc.record({
+                            nickname: fc.string(),
+                            postdate: fc.nat(),
+                            vizhash: fc.string()
+                        })
                     })
                     })
-                })
-            ),
-            'nat',
-            'bool',
-            'string',
-            'string',
-            jsc.elements(['loading', 'danger', 'other']),
-            'nestring',
-            function (comments, commentKey, fadeOut, nickname, message, alertType, alert) {
-                var clean = jsdom(),
-                    results = [];
-                $('body').html(
-                    '<div id="discussion"><h4>Discussion</h4>' +
-                    '<div id="commentcontainer"></div></div><div id="templates">' +
-                    '<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">Reply</button>' +
-                    '</article><p id="commenttailtemplate" class="comment">' +
-                    '<button class="btn btn-default btn-sm">Add comment</button>' +
-                    '</p><div id="replytemplate" class="reply hidden">' +
-                    '<input type="text" id="nickname" class="form-control" ' +
-                    'title="Optional nickname…" placeholder="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">Post comment</button></div></div>'
-                );
-                $.PrivateBin.Model.init();
-                $.PrivateBin.DiscussionViewer.init();
-                results.push(
-                    !$('#discussion').hasClass('hidden')
-                );
-                $.PrivateBin.DiscussionViewer.prepareNewDiscussion();
-                results.push(
-                    $('#discussion').hasClass('hidden')
-                );
-                comments.forEach(function (comment) {
-                    comment.id = comment.idArray.join('');
-                    comment.parentid = comment.parentidArray.join('');
-                    $.PrivateBin.DiscussionViewer.addComment($.PrivateBin.Helper.CommentFactory(comment), comment.data, comment.meta.nickname);
-                });
-                results.push(
-                    $('#discussion').hasClass('hidden')
-                );
-                $.PrivateBin.DiscussionViewer.finishDiscussion();
-                results.push(
-                    !$('#discussion').hasClass('hidden') &&
-                    comments.length + 1 >= $('#commentcontainer').children().length
-                );
-                if (comments.length > 0) {
-                    if (commentKey >= comments.length) {
-                        commentKey = commentKey % comments.length;
+                ),
+                fc.nat(),
+                fc.boolean(),
+                fc.string(),
+                fc.string(),
+                fc.constantFrom('loading', 'danger', 'other'),
+                fc.string({minLength: 1}),
+                function (comments, commentKey, fadeOut, nickname, message, alertType, alert) {
+                    var clean = globalThis.cleanup(),
+                        results = [];
+                    PrivateBin.Model.reset();
+                    PrivateBin.Helper.reset();
+                    document.body.innerHTML = (
+                        '<div id="status" class="hidden"></div>' +
+                        '<div id="errormessage" class="hidden"></div>' +
+                        `<div id="discussion">
+	<h4>Discussion</h4>
+	<div id="commentcontainer"></div>
+</div>
+<div id="templates">
+	<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">Reply</button>
+	</article>
+	<p id="commenttailtemplate" class="comment">
+		<button class="btn btn-default btn-sm">Add comment</button>
+	</p>
+	<div id="replytemplate" class="reply hidden">
+		<input type="text" id="nickname" class="form-control" title="Optional nickname…"
+			placeholder="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">Post comment</button>
+	</div>
+</div>
+`
+                    );
+                    PrivateBin.Model.init();
+                    PrivateBin.Alert.init();
+                    PrivateBin.DiscussionViewer.init();
+                    results.push(
+                        !document.getElementById('discussion').classList.contains('hidden')
+                    );
+                    PrivateBin.DiscussionViewer.prepareNewDiscussion();
+                    results.push(
+                        document.getElementById('discussion').classList.contains('hidden')
+                    );
+                    comments.forEach(function (comment) {
+                        comment.id = comment.idArray.join('');
+                        comment.parentid = comment.parentidArray.join('');
+                        comment.getCreated = function() { return comment.meta.postdate; };
+                        comment.getIcon = function() { return comment.meta.vizhash; };
+                        PrivateBin.DiscussionViewer.addComment(comment, comment.data, comment.meta.nickname);
+                    });
+                    results.push(
+                        document.getElementById('discussion').classList.contains('hidden')
+                    );
+                    PrivateBin.DiscussionViewer.finishDiscussion();
+                    results.push(
+                        !document.getElementById('discussion').classList.contains('hidden') &&
+                        comments.length + 1 >= document.getElementById('commentcontainer').children.length
+                    );
+                    if (comments.length > 0) {
+                        if (commentKey >= comments.length) {
+                            commentKey = commentKey % comments.length;
+                        }
+                        PrivateBin.DiscussionViewer.highlightComment(comments[commentKey].id, fadeOut);
+                        results.push(
+                            document.getElementById('comment_' + comments[commentKey].id).classList.contains('highlight')
+                        );
                     }
                     }
-                    $.PrivateBin.DiscussionViewer.highlightComment(comments[commentKey].id, fadeOut);
+                    // clicking "Add comment" button should open the reply form
+                    document.getElementById('commentcontainer').querySelector('button').click();
                     results.push(
                     results.push(
-                        $('#comment_' + comments[commentKey].id).hasClass('highlight')
+                        document.getElementById('reply') !== null &&
+                        !document.getElementById('reply').classList.contains('hidden')
                     );
                     );
-                }
-                $('#commentcontainer').find('button')[0].click();
-                results.push(
-                    !$('#reply').hasClass('hidden')
-                );
-                $('#reply #nickname').val(nickname);
-                $('#reply #replymessage').val(message);
-                $.PrivateBin.DiscussionViewer.getReplyCommentId();
-                results.push(
-                    $.PrivateBin.DiscussionViewer.getReplyNickname() === $('#reply #nickname').val() &&
-                    $.PrivateBin.DiscussionViewer.getReplyMessage() === $('#reply #replymessage').val()
-                );
-                var notificationResult = $.PrivateBin.DiscussionViewer.handleNotification(alertType === 'other' ? alert : alertType);
-                if (alertType === 'loading') {
-                    results.push(notificationResult === false);
-                } else {
+                    document.querySelector('#reply #nickname').value = nickname;
+                    document.querySelector('#reply #replymessage').value = message;
+                    PrivateBin.DiscussionViewer.getReplyCommentId();
                     results.push(
                     results.push(
-                        alertType === 'danger' ? (
-                            notificationResult.hasClass('alert-danger') &&
-                            !notificationResult.hasClass('alert-info')
-                        ) : (
-                            !notificationResult.hasClass('alert-danger') &&
-                            notificationResult.hasClass('alert-info')
-                        )
+                        PrivateBin.DiscussionViewer.getReplyNickname() === document.querySelector('#reply #nickname').value &&
+                        PrivateBin.DiscussionViewer.getReplyMessage() === document.querySelector('#reply #replymessage').value
                     );
                     );
+                    var notificationResult = PrivateBin.DiscussionViewer.handleNotification(alertType === 'other' ? alert : alertType);
+                    if (alertType === 'loading') {
+                        results.push(notificationResult === false);
+                    } else {
+                        results.push(
+                            alertType === 'danger' ? (
+                                notificationResult.classList.contains('alert-danger') &&
+                                !notificationResult.classList.contains('alert-info')
+                            ) : (
+                                !notificationResult.classList.contains('alert-danger') &&
+                                notificationResult.classList.contains('alert-info')
+                            )
+                        );
+                    }
+                    clean();
+                    return results.every(element => element);
                 }
                 }
-                clean();
-                return results.every(element => element);
-            }
-        );
+            ));
+        });
     });
     });
 });
 });

+ 70 - 61
js/test/Editor.js

@@ -1,70 +1,79 @@
 'use strict';
 'use strict';
 require('../common');
 require('../common');
+const fc = require('fast-check');
 
 
 describe('Editor', function () {
 describe('Editor', function () {
     describe('show, hide, getText, setText & isPreview', function () {
     describe('show, hide, getText, setText & isPreview', function () {
         this.timeout(30000);
         this.timeout(30000);
 
 
-        jsc.property(
-            'returns text fed into the textarea, handles editor tabs',
-            'string',
-            function (text) {
-                var clean = jsdom(),
-                    results = [];
-                $('body').html(
-                    '<ul id="editorTabs" class="nav nav-tabs hidden"><li ' +
-                    'role="presentation" class="active"><a id="messageedit" ' +
-                    'href="#">Editor</a></li><li role="presentation"><a ' +
-                    'id="messagepreview" href="#">Preview</a></li></ul><div ' +
-                    'id="placeholder" class="hidden">+++ no document text +++</div>' +
-                    '<div id="prettymessage" class="hidden"><pre id="prettyprint" ' +
-                    'class="prettyprint linenums:1"></pre></div><div ' +
-                    'id="plaintext" class="hidden"></div><p><textarea ' +
-                    'id="message" name="message" cols="80" rows="25" ' +
-                    'class="form-control hidden"></textarea></p>'
-                );
-                $.PrivateBin.Editor.init();
-                results.push(
-                    $('#editorTabs').hasClass('hidden') &&
-                    $('#message').hasClass('hidden')
-                );
-                $.PrivateBin.Editor.show();
-                results.push(
-                    !$('#editorTabs').hasClass('hidden') &&
-                    !$('#message').hasClass('hidden')
-                );
-                $.PrivateBin.Editor.hide();
-                results.push(
-                    $('#editorTabs').hasClass('hidden') &&
-                    $('#message').hasClass('hidden')
-                );
-                $.PrivateBin.Editor.show();
-                $.PrivateBin.Editor.focusInput();
-                results.push(
-                    $.PrivateBin.Editor.getText().length === 0
-                );
-                $.PrivateBin.Editor.setText(text);
-                results.push(
-                    $.PrivateBin.Editor.getText() === $('#message').val()
-                );
-                $.PrivateBin.Editor.setText();
-                results.push(
-                    !$.PrivateBin.Editor.isPreview() &&
-                    !$('#message').hasClass('hidden')
-                );
-                $('#messagepreview').trigger('click');
-                results.push(
-                    $.PrivateBin.Editor.isPreview() &&
-                    $('#message').hasClass('hidden')
-                );
-                $('#messageedit').trigger('click');
-                results.push(
-                    !$.PrivateBin.Editor.isPreview() &&
-                    !$('#message').hasClass('hidden')
-                );
-                clean();
-                return results.every(element => element);
-            }
-        );
+        it('returns text fed into the textarea, handles editor tabs', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (text) {
+                    var clean = globalThis.cleanup(),
+                        results = [];
+                    document.body.innerHTML = (
+                        '<ul id="editorTabs" class="nav nav-tabs hidden">' +
+                            '<li role="presentation" class="active">' +
+                                '<a id="messageedit" href="#">Editor</a>' +
+                            '</li>' +
+                            '<li role="presentation">' +
+                                '<a id="messagepreview" href="#">Preview</a>' +
+                            '</li>' +
+                        '</ul>' +
+                        '<div id="placeholder" class="hidden">+++ no document text +++</div>' +
+                        '<div id="prettymessage" class="hidden">' +
+                            '<pre id="prettyprint" class="prettyprint linenums:1"></pre>' +
+                        '</div>' +
+                        '<div id="plaintext" class="hidden"></div>' +
+                        '<p>' +
+                            '<textarea id="message" name="message" cols="80" rows="25" class="form-control hidden"></textarea>' +
+                        '</p>' +
+                        '<input id="messagetab" type="checkbox" checked="checked" />'
+                    );
+                    PrivateBin.Editor.init();
+                    results.push(
+                        document.getElementById('editorTabs').classList.contains('hidden') &&
+                        document.getElementById('message').classList.contains('hidden')
+                    );
+                    PrivateBin.Editor.show();
+                    results.push(
+                        !document.getElementById('editorTabs').classList.contains('hidden') &&
+                        !document.getElementById('message').classList.contains('hidden')
+                    );
+                    PrivateBin.Editor.hide();
+                    results.push(
+                        document.getElementById('editorTabs').classList.contains('hidden') &&
+                        document.getElementById('message').classList.contains('hidden')
+                    );
+                    PrivateBin.Editor.show();
+                    PrivateBin.Editor.focusInput();
+                    results.push(
+                        PrivateBin.Editor.getText().length === 0
+                    );
+                    PrivateBin.Editor.setText(text);
+                    results.push(
+                        PrivateBin.Editor.getText() === document.getElementById('message').value
+                    );
+                    PrivateBin.Editor.setText();
+                    results.push(
+                        !PrivateBin.Editor.isPreview() &&
+                        !document.getElementById('message').classList.contains('hidden')
+                    );
+                    document.getElementById('messagepreview').click();
+                    results.push(
+                        PrivateBin.Editor.isPreview() &&
+                        document.getElementById('message').classList.contains('hidden')
+                    );
+                    document.getElementById('messageedit').click();
+                    results.push(
+                        !PrivateBin.Editor.isPreview() &&
+                        !document.getElementById('message').classList.contains('hidden')
+                    );
+                    clean();
+                    return results.every(element => element);
+                }
+            ));
+        });
     });
     });
 });
 });

+ 316 - 268
js/test/Helper.js

@@ -1,45 +1,68 @@
 'use strict';
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 
 describe('Helper', function () {
 describe('Helper', function () {
     describe('secondsToHuman', function () {
     describe('secondsToHuman', function () {
-        jsc.property('returns an array with a number and a word', 'integer', function (number) {
-            var result = $.PrivateBin.Helper.secondsToHuman(number);
-            return Array.isArray(result) &&
-                result.length === 2 &&
-                result[0] === parseInt(result[0], 10) &&
-                typeof result[1] === 'string';
+        it('returns an array with a number and a word', () => {
+            fc.assert(fc.property(fc.integer(), function (number) {
+                var result = PrivateBin.Helper.secondsToHuman(number);
+                return Array.isArray(result) &&
+                    result.length === 2 &&
+                    result[0] === parseInt(result[0], 10) &&
+                    typeof result[1] === 'string';
+            }));
         });
         });
-        jsc.property('returns seconds on the first array position', 'integer 59', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[0] === number;
+        it('returns seconds on the first array position', () => {
+            fc.assert(fc.property(fc.integer({max: 59}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[0] === number;
+            }));
         });
         });
-        jsc.property('returns seconds on the second array position', 'integer 59', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'second';
+        it('returns seconds on the second array position', () => {
+            fc.assert(fc.property(fc.integer({max: 59}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[1] === 'second';
+            }));
         });
         });
-        jsc.property('returns minutes on the first array position', 'integer 60 3599', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / 60);
+        it('returns minutes on the first array position', () => {
+            fc.assert(fc.property(fc.integer({min: 60, max: 3599}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / 60);
+            }));
         });
         });
-        jsc.property('returns minutes on the second array position', 'integer 60 3599', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'minute';
+        it('returns minutes on the second array position', () => {
+            fc.assert(fc.property(fc.integer({min: 60, max: 3599}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[1] === 'minute';
+            }));
         });
         });
-        jsc.property('returns hours on the first array position', 'integer 3600 86399', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60));
+        it('returns hours on the first array position', () => {
+            fc.assert(fc.property(fc.integer({min: 3600, max: 86399}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60));
+            }));
         });
         });
-        jsc.property('returns hours on the second array position', 'integer 3600 86399', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'hour';
+        it('returns hours on the second array position', () => {
+            fc.assert(fc.property(fc.integer({min: 3600, max: 86399}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[1] === 'hour';
+            }));
         });
         });
-        jsc.property('returns days on the first array position', 'integer 86400 5184000', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24));
+        it('returns days on the first array position', () => {
+            fc.assert(fc.property(fc.integer({min: 86400, max: 5184000}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24));
+            }));
         });
         });
-        jsc.property('returns days on the second array position', 'integer 86400 5184000', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'day';
+        it('returns days on the second array position', () => {
+            fc.assert(fc.property(fc.integer({min: 86400, max: 5184000}), function (number) {
+                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) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24 * 30));
+        it('returns months on the first array position', () => {
+            fc.assert(fc.property(fc.integer({min: 5184001}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24 * 30));
+            }));
         });
         });
-        jsc.property('returns months on the second array position', 'integer 5184000 9007199254740991', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'month';
+        it('returns months on the second array position', () => {
+            fc.assert(fc.property(fc.integer({min: 5184001}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[1] === 'month';
+            }));
         });
         });
     });
     });
 
 
@@ -47,307 +70,332 @@ describe('Helper', function () {
     // TODO: This needs to be tested using a browser.
     // TODO: This needs to be tested using a browser.
     describe('selectText', function () {
     describe('selectText', function () {
         this.timeout(30000);
         this.timeout(30000);
-        jsc.property(
-            'selection contains content of given ID',
-            jsc.nearray(jsc.nearray(common.jscAlnumString())),
-            'nearray string',
-            function (ids, contents) {
-                var html = '',
-                    result = true,
-                    clean = jsdom(html);
-                ids.forEach(function(item, i) {
-                    html += '<div id="' + item.join('') + '">' + $.PrivateBin.Helper.htmlEntities(contents[i] || contents[0]) + '</div>';
-                });
-                // TODO: As per https://github.com/tmpvar/jsdom/issues/321 there is no getSelection in jsdom, yet.
-                // Once there is one, uncomment the block below to actually check the result.
-                /*
-                ids.forEach(function(item, i) {
-                    $.PrivateBin.Helper.selectText(item.join(''));
-                    result *= (contents[i] || contents[0]) === window.getSelection().toString();
-                });
-                */
-                clean();
-                return Boolean(result);
-            }
-        );
+        it('selection contains content of given ID', () => {
+            fc.assert(fc.property(
+                fc.array(fc.array(common.fcAlnumString(), {minLength: 1}), {minLength: 1}),
+                fc.array(fc.string(), {minLength: 1}),
+                function (ids, contents) {
+                    var html = '',
+                        result = true,
+                        clean = globalThis.cleanup(html);
+                    ids.forEach(function(item, i) {
+                        html += '<div id="' + item.join('') + '">' + PrivateBin.Helper.htmlEntities(contents[i] || contents[0]) + '</div>';
+                    });
+                    // TODO: As per https://github.com/tmpvar/jsdom/issues/321 there is no getSelection in jsdom, yet.
+                    // Once there is one, uncomment the block below to actually check the result.
+                    /*
+                    ids.forEach(function(item, i) {
+                        PrivateBin.Helper.selectText(item.join(''));
+                        result *= (contents[i] || contents[0]) === window.getSelection().toString();
+                    });
+                    */
+                    clean();
+                    return Boolean(result);
+                }
+            ));
+        });
     });
     });
 
 
     describe('urls2links', function () {
     describe('urls2links', function () {
+        function getTextAsRenderedHtml(stringContent) {
+            const tempDiv = document.createElement('div');
+            tempDiv.textContent = stringContent;
+            return tempDiv.innerHTML;
+        }
+
         this.timeout(30000);
         this.timeout(30000);
         before(function () {
         before(function () {
-            cleanup = jsdom();
+            globalThis.cleanup();
         });
         });
 
 
-        jsc.property(
-            'ignores non-URL content',
-            'string',
-            function (content) {
-                // eslint-disable-next-line no-control-regex
-                content = content.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
-                let clean = jsdom();
-                $('body').html('<div id="foo"></div>');
-                let e = $('#foo');
-                e.text(content);
-                $.PrivateBin.Helper.urls2links(e);
-                let result = e.text();
-                clean();
-                return content === result;
-            }
-        );
-        jsc.property(
-            'replaces URLs with anchors',
-            'string',
-            common.jscUrl(),
-            jsc.array(common.jscHashString()),
-            'string',
-            function (prefix, url, fragment, postfix) {
-                // eslint-disable-next-line no-control-regex
-                prefix = prefix.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
-                // eslint-disable-next-line no-control-regex
-                postfix  = ' ' + postfix.replace(/\r/g, '\n').replace(/\u0000/g, '');
-                url.fragment = fragment.join('');
-                let urlString = common.urlToString(url),
-                    clean = jsdom();
-                $('body').html('<div id="foo"></div>');
-                let e = $('#foo');
+        it('ignores non-URL content', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (content) {
+                    // eslint-disable-next-line no-control-regex
+                    content = content.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
+                    let clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="foo"></div>';
+                    let e = document.getElementById('foo');
+                    e.textContent = content;
+                    PrivateBin.Helper.urls2links(e);
+                    let result = e.textContent;
+                    clean();
+                    return content === result;
+                }
+            ));
+        });
+        it('replaces URLs with anchors', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                common.fcUrl(),
+                fc.array(common.fcHashString()),
+                fc.string(),
+                function (prefix, url, fragment, postfix) {
+                    // eslint-disable-next-line no-control-regex
+                    prefix = prefix.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
+                    // eslint-disable-next-line no-control-regex
+                    postfix  = ' ' + postfix.replace(/\r/g, '\n').replace(/\u0000/g, '');
+                    url.fragment = fragment.join('');
+                    let urlString = common.urlToString(url),
+                        clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="foo"></div>';
+                    let e = document.getElementById('foo');
+
+                    // special cases: When the query string and fragment imply the beginning of an HTML entity, eg. &#0 or &#x
+                    if (
+                        url.query[-1] === '&' &&
+                        (parseInt(url.fragment.charAt(0), 10) >= 0 || url.fragment.charAt(0) === 'x')
+                    ) {
+                        url.query.pop();
+                        urlString = common.urlToString(url);
+                        postfix = '';
+                    }
+                    e.textContent = prefix + urlString + postfix;
+                    PrivateBin.Helper.urls2links(e);
+
+                    let result = e.innerHTML;
+                    clean();
 
 
-                // special cases: When the query string and fragment imply the beginning of an HTML entity, eg. &#0 or &#x
-                if (
-                    url.query[-1] === '&' &&
-                    (parseInt(url.fragment.charAt(0), 10) >= 0 || url.fragment.charAt(0) === 'x')
-                ) {
-                    url.query.pop();
-                    urlString = common.urlToString(url);
-                    postfix = '';
+                    urlString = getTextAsRenderedHtml(urlString);
+                    const expected = getTextAsRenderedHtml(prefix) + '<a href="' + urlString + '" target="_blank" rel="nofollow noopener noreferrer">' + urlString + '</a>' + getTextAsRenderedHtml(postfix);
+                    const expectedElement = document.createElement('div');
+                    expectedElement.innerHTML = expected;
+                    return expectedElement.innerHTML === result;
+                }
+            ));
+        });
+        it('replaces magnet links with anchors', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(common.fcQueryString()),
+                fc.string(),
+                function (prefix, query, postfix) {
+                    // eslint-disable-next-line no-control-regex
+                    prefix = prefix.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
+                    // eslint-disable-next-line no-control-regex
+                    postfix = ' ' + postfix.replace(/\r/g, '\n').replace(/\u0000/g, '');
+                    let url  = 'magnet:?' + query.join('').replace(/^&+|&+$/gm, ''),
+                        clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="foo"></div>';
+                    let e = document.getElementById('foo');
+                    e.textContent = prefix + url + postfix;
+                    PrivateBin.Helper.urls2links(e);
+                    let result = e.innerHTML;
+                    clean();
+                    url = getTextAsRenderedHtml(url);
+                    const expected = getTextAsRenderedHtml(prefix) + '<a href="' + url + '" target="_blank" rel="nofollow noopener noreferrer">' + url + '</a>' + getTextAsRenderedHtml(postfix);
+                    const expectedElement = document.createElement('div');
+                    expectedElement.innerHTML = expected;
+                    return expectedElement.innerHTML === result;
                 }
                 }
-                e.text(prefix + urlString + postfix);
-                $.PrivateBin.Helper.urls2links(e);
-                let result = e.html();
-                clean();
-                urlString = $('<div />').text(urlString).html();
-                const expected = $('<div />').text(prefix).html() + '<a href="' + urlString + '" target="_blank" rel="nofollow noopener noreferrer">' + urlString + '</a>' + $('<div />').text(postfix).html();
-                return expected === result;
-            }
-        );
-        jsc.property(
-            'replaces magnet links with anchors',
-            'string',
-            jsc.array(common.jscQueryString()),
-            'string',
-            function (prefix, query, postfix) {
-                // eslint-disable-next-line no-control-regex
-                prefix = prefix.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
-                // eslint-disable-next-line no-control-regex
-                postfix = ' ' + postfix.replace(/\r/g, '\n').replace(/\u0000/g, '');
-                let url  = 'magnet:?' + query.join('').replace(/^&+|&+$/gm, ''),
-                    clean = jsdom();
-                $('body').html('<div id="foo"></div>');
-                let e = $('#foo');
-                e.text(prefix + url + postfix);
-                $.PrivateBin.Helper.urls2links(e);
-                let result = e.html();
-                clean();
-                url = $('<div />').text(url).html();
-                return $('<div />').text(prefix).html() + '<a href="' + url + '" target="_blank" rel="nofollow noopener noreferrer">' + url + '</a>' + $('<div />').text(postfix).html() === result;
-            }
-        );
+            ));
+        });
     });
     });
 
 
     describe('sprintf', function () {
     describe('sprintf', function () {
-        jsc.property(
-            'replaces %s in strings with first given parameter',
-            'string',
-            '(small nearray) string',
-            'string',
-            function (prefix, params, postfix) {
-                prefix    =    prefix.replace(/%(s|d)/g, '%%');
-                params[0] = params[0].replace(/%(s|d)/g, '%%');
-                postfix   =   postfix.replace(/%(s|d)/g, '%%');
-                var 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) {
-                prefix  =  prefix.replace(/%(s|d)/g, '%%');
-                postfix = postfix.replace(/%(s|d)/g, '%%');
-                var 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) {
-                prefix  =  prefix.replace(/%(s|d)/g, '%%');
-                postfix = postfix.replace(/%(s|d)/g, '%%');
-                var 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) {
-                prefix  =  prefix.replace(/%(s|d)/g, '');
-                middle  =  middle.replace(/%(s|d)/g, '');
-                postfix = postfix.replace(/%(s|d)/g, '');
-                var 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) {
-                prefix  =  prefix.replace(/%(s|d)/g, '');
-                middle  =  middle.replace(/%(s|d)/g, '');
-                postfix = postfix.replace(/%(s|d)/g, '');
-                var params = [prefix + '%s' + middle + '%d' + postfix, string, uint],
-                    result = prefix + string + middle + uint + postfix;
-                return result === $.PrivateBin.Helper.sprintf.apply(this, params);
-            }
-        );
+        it('replaces %s in strings with first given parameter', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.string(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix    =    prefix.replace(/%(s|d)/g, '%%');
+                    params[0] = params[0].replace(/%(s|d)/g, '%%');
+                    postfix   =   postfix.replace(/%(s|d)/g, '%%');
+                    var result = prefix + params[0] + postfix;
+                    params.unshift(prefix + '%s' + postfix);
+                    return result === PrivateBin.Helper.sprintf.apply(this, params);
+                }
+            ));
+        });
+        it('replaces %d in strings with first given parameter', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.nat(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix  =  prefix.replace(/%(s|d)/g, '%%');
+                    postfix = postfix.replace(/%(s|d)/g, '%%');
+                    var result = prefix + params[0] + postfix;
+                    params.unshift(prefix + '%d' + postfix);
+                    return result === PrivateBin.Helper.sprintf.apply(this, params);
+                }
+            ));
+        });
+        it('replaces %d in strings with 0 if first parameter is not a number', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.falsy(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix  =  prefix.replace(/%(s|d)/g, '%%');
+                    postfix = postfix.replace(/%(s|d)/g, '%%');
+                    var result = prefix + '0' + postfix;
+                    params.unshift(prefix + '%d' + postfix);
+                    return result === PrivateBin.Helper.sprintf.apply(this, params);
+                }
+            ));
+        });
+        it('replaces %d and %s in strings in order', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.nat(),
+                fc.string(),
+                fc.string(),
+                fc.string(),
+                function (prefix, uint, middle, string, postfix) {
+                    prefix  =  prefix.replace(/%(s|d)/g, '');
+                    middle  =  middle.replace(/%(s|d)/g, '');
+                    postfix = postfix.replace(/%(s|d)/g, '');
+                    var params = [prefix + '%d' + middle + '%s' + postfix, uint, string],
+                        result = prefix + uint + middle + string + postfix;
+                    return result === PrivateBin.Helper.sprintf.apply(this, params);
+                }
+            ));
+        });
+        it('replaces %d and %s in strings in reverse order', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.nat(),
+                fc.string(),
+                fc.string(),
+                fc.string(),
+                function (prefix, uint, middle, string, postfix) {
+                    prefix  =  prefix.replace(/%(s|d)/g, '');
+                    middle  =  middle.replace(/%(s|d)/g, '');
+                    postfix = postfix.replace(/%(s|d)/g, '');
+                    var 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 () {
     describe('getCookie', function () {
         this.timeout(30000);
         this.timeout(30000);
         before(function () {
         before(function () {
-            cleanup();
+            globalThis.cleanup();
         });
         });
 
 
 /* TODO test fails since jsDOM version 17 - document.cookie remains empty
 /* TODO test fails since jsDOM version 17 - document.cookie remains empty
-        jsc.property(
-            'returns the requested cookie',
-            jsc.nearray(jsc.nearray(common.jscAlnumString())),
-            jsc.nearray(jsc.nearray(common.jscAlnumString())),
-            function (labels, values) {
-                let selectedKey = '', selectedValue = '';
-                const clean = jsdom();
-                labels.forEach(function(item, i) {
-                    const key = item.join(''),
-                        value = (values[i] || values[0]).join('');
-                    document.cookie = key + '=' + value;
-                    if (Math.random() < 1 / i || selectedKey === key)
-                    {
-                        selectedKey = key;
-                        selectedValue = value;
-                    }
-                });
-                const result = $.PrivateBin.Helper.getCookie(selectedKey);
-                $.PrivateBin.Helper.reset();
-                clean();
-                return result === selectedValue;
-            }
-        ); */
+        it('returns the requested cookie', () => {
+            fc.assert(fc.property(
+                fc.array(fc.array(common.fcAlnumString(), {minLength: 1}), {minLength: 1}),
+                fc.array(fc.array(common.fcAlnumString(), {minLength: 1}), {minLength: 1}),
+                function (labels, values) {
+                    let selectedKey = '', selectedValue = '';
+                    const clean = globalThis.cleanup();
+                    labels.forEach(function(item, i) {
+                        const key = item.join(''),
+                            value = (values[i] || values[0]).join('');
+                        document.cookie = key + '=' + value;
+                        if (Math.random() < 1 / i || selectedKey === key)
+                        {
+                            selectedKey = key;
+                            selectedValue = value;
+                        }
+                    });
+                    const result = PrivateBin.Helper.getCookie(selectedKey);
+                    PrivateBin.Helper.reset();
+                    clean();
+                    return result === selectedValue;
+                }
+            ));
+        }); */
     });
     });
 
 
     describe('baseUri', function () {
     describe('baseUri', function () {
         this.timeout(30000);
         this.timeout(30000);
-        jsc.property(
-            'returns the URL without query & fragment',
-            common.jscSchemas(false),
-            common.jscUrl(),
-            function (schema, url) {
-                url.schema = schema;
-                const fullUrl = common.urlToString(url);
-                delete(url.query);
-                delete(url.fragment);
-                $.PrivateBin.Helper.reset();
-                const expected = common.urlToString(url),
-                    clean = jsdom('', {url: fullUrl}),
-                    result = $.PrivateBin.Helper.baseUri();
-                clean();
-                return expected === result;
-            }
-        );
+        it('returns the URL without query & fragment', () => {
+            fc.assert(fc.property(
+                common.fcSchemas(false),
+                common.fcUrl(),
+                function (schema, url) {
+                    url.schema = schema;
+                    const fullUrl = common.urlToString(url);
+                    delete(url.query);
+                    delete(url.fragment);
+                    PrivateBin.Helper.reset();
+                    const expected = common.urlToString(url),
+                        clean = globalThis.cleanup('', {url: fullUrl}),
+                        result = PrivateBin.Helper.baseUri();
+                    clean();
+                    return expected === result;
+                }
+            ));
+        });
     });
     });
 
 
     describe('htmlEntities', function () {
     describe('htmlEntities', function () {
         before(function () {
         before(function () {
-            cleanup = jsdom();
+            globalThis.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)));
-            }
-        );
+        it('removes all HTML entities from any given string', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (string) {
+                    var result = PrivateBin.Helper.htmlEntities(string);
+                    return !(/[<>]/.test(result)) && !(string.indexOf('&') > -1 && !(/&amp;/.test(result)));
+                }
+            ));
+        });
     });
     });
 
 
     describe('formatBytes', function () {
     describe('formatBytes', function () {
-        jsc.property('returns 0 B for 0 bytes', function () {
-            return $.PrivateBin.Helper.formatBytes(0) === '0 B';
+        it('returns 0 B for 0 bytes', function () {
+            return PrivateBin.Helper.formatBytes(0) === '0 B';
         });
         });
 
 
-        jsc.property('formats bytes < 1000 as B', function () {
-            return $.PrivateBin.Helper.formatBytes(500) === '500 B';
+        it('formats bytes < 1000 as B', function () {
+            return PrivateBin.Helper.formatBytes(500) === '500 B';
         });
         });
 
 
-        jsc.property('formats kilobytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(1500) === '1.5 kB';
+        it('formats kilobytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(1500) === '1.5 kB';
         });
         });
 
 
-        jsc.property('formats megabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(2 * 1000 * 1000) === '2 MB';
+        it('formats megabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(2 * 1000 * 1000) === '2 MB';
         });
         });
 
 
-        jsc.property('formats gigabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(3.45 * 1000 * 1000 * 1000) === '3.45 GB';
+        it('formats gigabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(3.45 * 1000 * 1000 * 1000) === '3.45 GB';
         });
         });
 
 
-        jsc.property('formats terabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(1.75 * 1000 ** 4) === '1.75 TB';
+        it('formats terabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(1.75 * 1000 ** 4) === '1.75 TB';
         });
         });
 
 
-        jsc.property('formats petabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(1.5 * 1000 ** 5) === '1.5 PB';
+        it('formats petabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(1.5 * 1000 ** 5) === '1.5 PB';
         });
         });
 
 
-        jsc.property('formats exabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(1.2345 * 1000 ** 6).startsWith('1.23 EB');
+        it('formats exabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(1.2345 * 1000 ** 6).startsWith('1.23 EB');
         });
         });
 
 
-        jsc.property('formats yottabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(1.23 * 1000 ** 8).startsWith('1.23 YB');
+        it('formats yottabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(1.23 * 1000 ** 8).startsWith('1.23 YB');
         });
         });
 
 
-        jsc.property('rounds to two decimal places', function () {
-            return $.PrivateBin.Helper.formatBytes(1234567) === '1.23 MB';
+        it('rounds to two decimal places', function () {
+            return PrivateBin.Helper.formatBytes(1234567) === '1.23 MB';
         });
         });
     });
     });
 
 
 
 
     describe('isBootstrap5', function () {
     describe('isBootstrap5', function () {
-        jsc.property('Bootstrap 5 has been detected', function () {
+        it('Bootstrap 5 has been detected', function () {
             global.bootstrap = {};
             global.bootstrap = {};
-            return $.PrivateBin.Helper.isBootstrap5() === true;
+            return PrivateBin.Helper.isBootstrap5() === true;
         });
         });
 
 
-        jsc.property('Bootstrap 5 has not been detected', function () {
+        it('Bootstrap 5 has not been detected', function () {
             delete global.bootstrap;
             delete global.bootstrap;
-            return $.PrivateBin.Helper.isBootstrap5() === false;
+            return PrivateBin.Helper.isBootstrap5() === false;
         });
         });
     });
     });
 });
 });

+ 265 - 196
js/test/I18n.js

@@ -1,177 +1,188 @@
 'use strict';
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 
 describe('I18n', function () {
 describe('I18n', function () {
     describe('translate', function () {
     describe('translate', function () {
         this.timeout(30000);
         this.timeout(30000);
         before(function () {
         before(function () {
-            $.PrivateBin.I18n.reset();
+            PrivateBin.I18n.reset();
         });
         });
 
 
-        jsc.property(
-            'returns message ID unchanged if no translation found',
-            'string',
-            function (messageId) {
-                messageId   = messageId.replace(/%(s|d)/g, '%%');
-                var plurals = [messageId, messageId + 's'],
-                    fake    = [messageId],
-                    result  = $.PrivateBin.I18n.translate(messageId);
-                $.PrivateBin.I18n.reset();
-
-                var alias = $.PrivateBin.I18n._(messageId);
-                $.PrivateBin.I18n.reset();
-
-                var pluralResult = $.PrivateBin.I18n.translate(plurals);
-                $.PrivateBin.I18n.reset();
-
-                var pluralAlias = $.PrivateBin.I18n._(plurals);
-                $.PrivateBin.I18n.reset();
-
-                var fakeResult = $.PrivateBin.I18n.translate(fake);
-                $.PrivateBin.I18n.reset();
-
-                var fakeAlias = $.PrivateBin.I18n._(fake);
-                $.PrivateBin.I18n.reset();
-
-                if (messageId.indexOf('<a') === -1) {
-                    messageId = $.PrivateBin.Helper.htmlEntities(messageId);
-                } else {
-                    messageId = DOMPurify.sanitize(
-                        messageId, {
-                            ALLOWED_TAGS: ['a', 'i', 'span'],
+        it('returns message ID unchanged if no translation found', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (messageId) {
+                    messageId   = messageId.replace(/%(s|d)/g, '%%');
+                    const plurals = [messageId, messageId + 's'],
+                          fake    = [messageId],
+                          result  = PrivateBin.I18n.translate(messageId);
+                    PrivateBin.I18n.reset();
+
+                    const alias = PrivateBin.I18n._(messageId);
+                    PrivateBin.I18n.reset();
+
+                    const pluralResult = PrivateBin.I18n.translate(plurals);
+                    PrivateBin.I18n.reset();
+
+                    const pluralAlias = PrivateBin.I18n._(plurals);
+                    PrivateBin.I18n.reset();
+
+                    const fakeResult = PrivateBin.I18n.translate(fake);
+                    PrivateBin.I18n.reset();
+
+                    const fakeAlias = PrivateBin.I18n._(fake);
+                    PrivateBin.I18n.reset();
+
+                    if (messageId.indexOf('<a') === -1) {
+                        messageId = PrivateBin.Helper.htmlEntities(messageId);
+                    } else {
+                        messageId = DOMPurify.sanitize(
+                            messageId, {
+                                ALLOWED_TAGS: ['a', 'i', 'span'],
+                                ALLOWED_ATTR: ['href', 'id']
+                            }
+                        );
+                    }
+                    return messageId === result && messageId === alias &&
+                        messageId === pluralResult && messageId === pluralAlias &&
+                        messageId === fakeResult && messageId === fakeAlias;
+                }
+            ));
+        });
+        it('replaces %s in strings with first given parameter, encoding all, when no link is in the messageID', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.string(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    params[0] = params[0].replace(/%(s|d)/g, '%%');
+                    postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    const translation = PrivateBin.Helper.htmlEntities(prefix + params[0] + postfix);
+                    params.unshift(prefix + '%s' + postfix);
+                    const result = PrivateBin.I18n.translate.apply(this, params);
+                    PrivateBin.I18n.reset();
+                    const alias = PrivateBin.I18n._.apply(this, params);
+                    PrivateBin.I18n.reset();
+                    return translation === result && translation === alias;
+                }
+            ));
+        });
+        it('replaces %s in strings with first given parameter, encoding params only, when a link is part of the messageID', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.string(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix    =    prefix.replace(/%(s|d)/g, '%%');
+                    params[0] = params[0].replace(/%(s|d)/g, '%%');
+                    postfix   =   postfix.replace(/%(s|d)/g, '%%');
+                    const translation = DOMPurify.sanitize(
+                        prefix + '<a href="' + params[0] + '"></a>' + postfix, {
+                            ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
+                            ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
                             ALLOWED_ATTR: ['href', 'id']
                             ALLOWED_ATTR: ['href', 'id']
                         }
                         }
                     );
                     );
+                    params.unshift(prefix + '<a href="%s"></a>' + postfix);
+                    const result = PrivateBin.I18n.translate.apply(this, params);
+                    PrivateBin.I18n.reset();
+                    const alias = PrivateBin.I18n._.apply(this, params);
+                    PrivateBin.I18n.reset();
+                    return translation === result && translation === alias;
                 }
                 }
-                return messageId === result && messageId === alias &&
-                    messageId === pluralResult && messageId === pluralAlias &&
-                    messageId === fakeResult && messageId === fakeAlias;
-            }
-        );
-        jsc.property(
-            'replaces %s in strings with first given parameter, encoding all, when no link is in the messageID',
-            'string',
-            '(small nearray) string',
-            'string',
-            function (prefix, params, postfix) {
-                prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                params[0] = params[0].replace(/%(s|d)/g, '%%');
-                postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                const translation = $.PrivateBin.Helper.htmlEntities(prefix + params[0] + postfix);
-                params.unshift(prefix + '%s' + postfix);
-                const result = $.PrivateBin.I18n.translate.apply(this, params);
-                $.PrivateBin.I18n.reset();
-                const alias = $.PrivateBin.I18n._.apply(this, params);
-                $.PrivateBin.I18n.reset();
-                return translation === result && translation === alias;
-            }
-        );
-        jsc.property(
-            'replaces %s in strings with first given parameter, encoding params only, when a link is part of the messageID',
-            'string',
-            '(small nearray) string',
-            'string',
-            function (prefix, params, postfix) {
-                prefix    =    prefix.replace(/%(s|d)/g, '%%');
-                params[0] = params[0].replace(/%(s|d)/g, '%%');
-                postfix   =   postfix.replace(/%(s|d)/g, '%%');
-                const translation = DOMPurify.sanitize(
-                    prefix + '<a href="' + params[0] + '"></a>' + postfix, {
-                        ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
-                        ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
-                        ALLOWED_ATTR: ['href', 'id']
-                    }
-                );
-                params.unshift(prefix + '<a href="%s"></a>' + postfix);
-                const result = $.PrivateBin.I18n.translate.apply(this, params);
-                $.PrivateBin.I18n.reset();
-                const alias = $.PrivateBin.I18n._.apply(this, params);
-                $.PrivateBin.I18n.reset();
-                return translation === result && translation === alias;
-            }
-        );
-        jsc.property(
-            'replaces %s in strings with first given parameter into an element, encoding all, when no link is in the messageID',
-            'string',
-            '(small nearray) string',
-            'string',
-            function (prefix, params, postfix) {
-                prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                params[0] = params[0].replace(/%(s|d)/g, '%%');
-                postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                const translation = $('<textarea>').text((prefix + params[0] + postfix)).text();
-                let args = Array.prototype.slice.call(params);
-                args.unshift(prefix + '%s' + postfix);
-                let clean = jsdom();
-                $('body').html('<div id="i18n"></div>');
-                args.unshift($('#i18n'));
-                $.PrivateBin.I18n.translate.apply(this, args);
-                const result = $('#i18n').text();
-                $.PrivateBin.I18n.reset();
-                clean();
-                clean = jsdom();
-                $('body').html('<div id="i18n"></div>');
-                args[0] = $('#i18n');
-                $.PrivateBin.I18n._.apply(this, args);
-                const alias = $('#i18n').text();
-                $.PrivateBin.I18n.reset();
-                clean();
-                return translation === result && translation === alias;
-            }
-        );
-        jsc.property(
-            'replaces %s in strings with first given parameter into an element, encoding params only, when a link is part of the messageID inserted',
-            'string',
-            '(small nearray) string',
-            'string',
-            function (prefix, params, postfix) {
-                prefix    =    prefix.replace(/%(s|d)/g, '%%').trim();
-                params[0] = params[0].replace(/%(s|d)/g, '%%').trim();
-                postfix   =   postfix.replace(/%(s|d)/g, '%%').trim();
-                const translation = DOMPurify.sanitize(
-                    prefix + '<a href="' + params[0] + '"></a>' + postfix, {
-                        ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
-                        ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
-                        ALLOWED_ATTR: ['href', 'id']
-                    }
-                );
-                let args = Array.prototype.slice.call(params);
-                args.unshift(prefix + '<a href="%s"></a>' + postfix);
-                let clean = jsdom();
-                $('body').html('<div id="i18n"></div>');
-                args.unshift($('#i18n'));
-                $.PrivateBin.I18n.translate.apply(this, args);
-                const result = $('#i18n').html();
-                $.PrivateBin.I18n.reset();
-                clean();
-                clean = jsdom();
-                $('body').html('<div id="i18n"></div>');
-                args[0] = $('#i18n');
-                $.PrivateBin.I18n._.apply(this, args);
-                const alias = $('#i18n').html();
-                $.PrivateBin.I18n.reset();
-                clean();
-                return translation === result && translation === alias;
-            }
-        );
+            ));
+        });
+        it('replaces %s in strings with first given parameter into an element, encoding all, when no link is in the messageID', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.string(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    params[0] = params[0].replace(/%(s|d)/g, '%%');
+                    postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    const tempDiv = document.createElement('textarea');
+                    tempDiv.textContent = (prefix + params[0] + postfix);
+                    const translation = tempDiv.textContent;
+                    let args = Array.prototype.slice.call(params);
+                    args.unshift(prefix + '%s' + postfix);
+                    let clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="i18n"></div>';
+                    const i18nElement = document.getElementById('i18n');
+                    args.unshift(i18nElement);
+                    PrivateBin.I18n.translate.apply(this, args);
+                    const result = i18nElement.textContent;
+                    PrivateBin.I18n.reset();
+                    clean();
+                    clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="i18n"></div>';
+                    args[0] = document.getElementById('i18n');
+                    PrivateBin.I18n._.apply(this, args);
+                    const alias = document.getElementById('i18n').textContent;
+                    PrivateBin.I18n.reset();
+                    clean();
+                    return translation === result && translation === alias;
+                }
+            ));
+        });
+        it('replaces %s in strings with first given parameter into an element, encoding params only, when a link is part of the messageID inserted', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.string(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix    =    prefix.replace(/%(s|d)/g, '%%').trim();
+                    params[0] = params[0].replace(/%(s|d)/g, '%%').trim();
+                    postfix   =   postfix.replace(/%(s|d)/g, '%%').trim();
+                    const translation = DOMPurify.sanitize(
+                        prefix + '<a href="' + params[0] + '"></a>' + postfix, {
+                            ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
+                            ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
+                            ALLOWED_ATTR: ['href', 'id']
+                        }
+                    );
+                    let args = Array.prototype.slice.call(params);
+                    args.unshift(prefix + '<a href="%s"></a>' + postfix);
+                    let clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="i18n"></div>';
+                    const i18nElement2 = document.getElementById('i18n');
+                    args.unshift(i18nElement2);
+                    PrivateBin.I18n.translate.apply(this, args);
+                    const result = i18nElement2.innerHTML;
+                    PrivateBin.I18n.reset();
+                    clean();
+                    clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="i18n"></div>';
+                    args[0] = document.getElementById('i18n');
+                    PrivateBin.I18n._.apply(this, args);
+                    const alias = document.getElementById('i18n').innerHTML;
+                    PrivateBin.I18n.reset();
+                    clean();
+                    return translation === result && translation === alias;
+                }
+            ));
+        });
     });
     });
 
 
     describe('getPluralForm', function () {
     describe('getPluralForm', function () {
         before(function () {
         before(function () {
-            $.PrivateBin.I18n.reset();
+            PrivateBin.I18n.reset();
         });
         });
 
 
-        jsc.property(
-            'returns valid key for plural form',
-            common.jscSupportedLanguages(),
-            'integer',
-            function(language, n) {
-                $.PrivateBin.I18n.reset(language);
-                var result = $.PrivateBin.I18n.getPluralForm(n);
-                // arabic seems to have the highest plural count with 6 forms
-                return result >= 0 && result <= 5;
-            }
-        );
+        it('returns valid key for plural form', () => {
+            fc.assert(fc.property(
+                common.fcSupportedLanguages(),
+                fc.integer(),
+                function(language, n) {
+                    PrivateBin.I18n.reset(language);
+                    const result = PrivateBin.I18n.getPluralForm(n);
+                    // arabic seems to have the highest plural count with 6 forms
+                    return result >= 0 && result <= 5;
+                }
+            ));
+        });
     });
     });
 
 
     // loading of JSON via AJAX needs to be tested in the browser, this just mocks it
     // loading of JSON via AJAX needs to be tested in the browser, this just mocks it
@@ -179,53 +190,111 @@ describe('I18n', function () {
     describe('loadTranslations', function () {
     describe('loadTranslations', function () {
         this.timeout(30000);
         this.timeout(30000);
         before(function () {
         before(function () {
-            $.PrivateBin.I18n.reset();
+            PrivateBin.I18n.reset();
         });
         });
 
 
-        jsc.property(
-            'downloads and handles any supported language',
-            common.jscSupportedLanguages(),
-            function(language) {
-                // cleanup
-                var clean = jsdom('', {cookie: ['lang=en']});
-                $.PrivateBin.I18n.reset('en');
-                $.PrivateBin.I18n.loadTranslations();
-                clean();
+        it('downloads and handles any supported language', () => {
+            fc.assert(fc.property(
+                common.fcSupportedLanguages(),
+                function(language) {
+                    // cleanup
+                    let clean = globalThis.cleanup('', {cookie: ['lang=en']});
+                    PrivateBin.I18n.reset('en');
+                    PrivateBin.I18n.loadTranslations();
+                    clean();
+
+                    // mock
+                    clean = globalThis.cleanup('', {cookie: ['lang=' + language]});
+                    // eslint-disable-next-line global-require
+                    PrivateBin.I18n.reset(language, require('../../i18n/' + language + '.json'));
+                    const loadedLang = PrivateBin.I18n.getLanguage(),
+                          result     = PrivateBin.I18n.translate('Never'),
+                          alias      = PrivateBin.I18n._('Never');
+                    clean();
+                    return language === loadedLang && result === alias;
+                }
+            ));
+        });
+
+        it('maps Traditional Chinese browser languages to zh-tw', async function () {
+            const clean = globalThis.cleanup('<script src="js/privatebin.js"></script>', {url: 'https://privatebin.net/'});
 
 
-                // mock
-                clean = jsdom('', {cookie: ['lang=' + language]});
-                // eslint-disable-next-line global-require
-                $.PrivateBin.I18n.reset(language, require('../../i18n/' + language + '.json'));
-                var loadedLang = $.PrivateBin.I18n.getLanguage(),
-                    result = $.PrivateBin.I18n.translate('Never'),
-                    alias  = $.PrivateBin.I18n._('Never');
+            // loadTranslations() loads the translation file via fetch() API,
+            // which is asynchronous. Stub it, so the test does not perform a
+            // real network request, and await the 'languageLoaded' event that
+            // loadTranslations() dispatches once the fetch has completed.
+            const originalFetch = globalThis.fetch;
+            globalThis.fetch = function (url) {
+                assert.strictEqual(url, 'i18n/zh-tw.json');
+                return Promise.resolve({
+                    ok: true,
+                    status: 200,
+                    statusText: 'OK',
+                    json: function () {
+                        return Promise.resolve({});
+                    }
+                });
+            };
+
+            try {
+                for (const language of ['zh-TW', 'zh-TW-u-nu-hanidec', 'zh-Hant', 'zh-Hant-TW', 'zh-HK', 'zh-MO']) {
+                    Object.defineProperty(navigator, 'language', {
+                        value: language,
+                        configurable: true
+                    });
+                    PrivateBin.I18n.reset('en');
+                    const loaded = new Promise(function (resolve) {
+                        document.addEventListener('languageLoaded', resolve, {once: true});
+                    });
+                    PrivateBin.I18n.loadTranslations();
+                    await loaded;
+                    assert.strictEqual(PrivateBin.I18n.getLanguage(), 'zh-tw', language);
+                }
+            } finally {
+                globalThis.fetch = originalFetch;
+                delete navigator.language;
+                PrivateBin.I18n.reset();
                 clean();
                 clean();
-                return language === loadedLang && result === alias;
             }
             }
-        );
-
-        jsc.property(
-            'should default to en',
-            function() {
-                var clean = jsdom('', {url: 'https://privatebin.net/'});
-
-                // when navigator.userLanguage is undefined and no default language
-                // is specified, it would throw an error
-                [ 'language', 'userLanguage' ].forEach(function (key) {
-                    Object.defineProperty(navigator, key, {
-                        value: undefined,
-                        writeable: false
-                    });
+        });
+
+        it('should default to en', () => {
+            const clean = globalThis.cleanup('', {url: 'https://privatebin.net/'});
+
+            // when navigator.userLanguage is undefined and no default language
+            // is specified, it would throw an error
+            [ 'language', 'userLanguage' ].forEach(function (key) {
+                Object.defineProperty(navigator, key, {
+                    value: undefined,
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
                 });
                 });
+            });
 
 
-                $.PrivateBin.I18n.reset('en');
-                $.PrivateBin.I18n.loadTranslations();
-                var result = $.PrivateBin.I18n.translate('Never'),
-                    alias  = $.PrivateBin.I18n._('Never');
+            PrivateBin.I18n.reset('en');
+            PrivateBin.I18n.loadTranslations();
+            const result = PrivateBin.I18n.translate('Never'),
+                  alias  = PrivateBin.I18n._('Never');
 
 
-                clean();
-                return 'Never' === result && 'Never' === alias;
-            }
-        );
+            clean();
+            return 'Never' === result && 'Never' === alias;
+        });
+    });
+
+    describe('onLanguageLoaded', function () {
+        before(function () {
+            PrivateBin.I18n.reset();
+        });
+
+        it('invokes the callback when the languageLoaded event is dispatched', function () {
+            let called = false;
+            PrivateBin.I18n.onLanguageLoaded(function () {
+                called = true;
+            });
+            assert.strictEqual(called, false);
+            document.dispatchEvent(new CustomEvent('languageLoaded'));
+            assert.strictEqual(called, true);
+        });
     });
     });
 });
 });

+ 234 - 197
js/test/Model.js

@@ -1,243 +1,280 @@
 'use strict';
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 
 describe('Model', function () {
 describe('Model', function () {
     describe('getExpirationDefault', function () {
     describe('getExpirationDefault', function () {
         before(function () {
         before(function () {
-            $.PrivateBin.Model.reset();
-            cleanup = jsdom();
+            PrivateBin.Model.reset();
+            globalThis.cleanup();
         });
         });
 
 
-        jsc.property(
-            'returns the contents of the element with id "pasteExpiration"',
-            'nearray asciinestring',
-            'string',
-            'small nat',
-            function (keys, value, key) {
-                keys = keys.map($.PrivateBin.Helper.htmlEntities);
-                value = $.PrivateBin.Helper.htmlEntities(value);
-                var content = keys.length > key ? keys[key] : keys[0],
-                    contents = '<select id="pasteExpiration" name="pasteExpiration">';
-                keys.forEach(function(item) {
-                    contents += '<option value="' + item + '"';
-                    if (item === content) {
-                        contents += ' selected="selected"';
-                    }
-                    contents += '>' + value + '</option>';
-                });
-                contents += '</select>';
-                $('body').html(contents);
-                var result = $.PrivateBin.Helper.htmlEntities(
-                    $.PrivateBin.Model.getExpirationDefault()
-                );
-                $.PrivateBin.Model.reset();
-                return content === result;
-            }
-        );
+        it('returns the contents of the element with id "pasteExpiration"', () => {
+            fc.assert(fc.property(
+                fc.array(fc.string({minLength: 1}), {minLength: 1}),
+                fc.string(),
+                fc.nat(),
+                function (keys, value, key) {
+                    keys = keys.map(PrivateBin.Helper.htmlEntities);
+                    value = PrivateBin.Helper.htmlEntities(value);
+                    var content = keys.length > key ? keys[key] : keys[0],
+                        contents = '<select id="pasteExpiration" name="pasteExpiration">';
+                    keys.forEach(function(item) {
+                        contents += '<option value="' + item + '"';
+                        if (item === content) {
+                            contents += ' selected="selected"';
+                        }
+                        contents += '>' + value + '</option>';
+                    });
+                    contents += '</select>';
+                    document.body.innerHTML = contents;
+                    var result = PrivateBin.Helper.htmlEntities(
+                        PrivateBin.Model.getExpirationDefault()
+                    );
+                    PrivateBin.Model.reset();
+                    return content === result;
+                }
+            ));
+        });
     });
     });
 
 
     describe('getFormatDefault', function () {
     describe('getFormatDefault', function () {
         before(function () {
         before(function () {
-            $.PrivateBin.Model.reset();
+            PrivateBin.Model.reset();
         });
         });
         after(function () {
         after(function () {
-            cleanup();
+            globalThis.cleanup();
         });
         });
 
 
-        jsc.property(
-            'returns the contents of the element with id "pasteFormatter"',
-            'nearray asciinestring',
-            'string',
-            'small nat',
-            function (keys, value, key) {
-                keys = keys.map($.PrivateBin.Helper.htmlEntities);
-                value = $.PrivateBin.Helper.htmlEntities(value);
-                var content = keys.length > key ? keys[key] : keys[0],
-                    contents = '<select id="pasteFormatter" name="pasteFormatter">';
-                keys.forEach(function(item) {
-                    contents += '<option value="' + item + '"';
-                    if (item === content) {
-                        contents += ' selected="selected"';
-                    }
-                    contents += '>' + value + '</option>';
-                });
-                contents += '</select>';
-                $('body').html(contents);
-                var result = $.PrivateBin.Helper.htmlEntities(
-                    $.PrivateBin.Model.getFormatDefault()
-                );
-                $.PrivateBin.Model.reset();
-                return content === result;
-            }
-        );
+        it('returns the contents of the element with id "pasteFormatter"', () => {
+            fc.assert(fc.property(
+                fc.array(fc.string({minLength: 1}), {minLength: 1}),
+                fc.string(),
+                fc.nat(),
+                function (keys, value, key) {
+                    keys = keys.map(PrivateBin.Helper.htmlEntities);
+                    value = PrivateBin.Helper.htmlEntities(value);
+                    var content = keys.length > key ? keys[key] : keys[0],
+                        contents = '<select id="pasteFormatter" name="pasteFormatter">';
+                    keys.forEach(function(item) {
+                        contents += '<option value="' + item + '"';
+                        if (item === content) {
+                            contents += ' selected="selected"';
+                        }
+                        contents += '>' + value + '</option>';
+                    });
+                    contents += '</select>';
+                    document.body.innerHTML = contents;
+                    var result = PrivateBin.Helper.htmlEntities(
+                        PrivateBin.Model.getFormatDefault()
+                    );
+                    PrivateBin.Model.reset();
+                    return content === result;
+                }
+            ));
+        });
     });
     });
 
 
     describe('getPasteId', function () {
     describe('getPasteId', function () {
         this.timeout(30000);
         this.timeout(30000);
         beforeEach(function () {
         beforeEach(function () {
-            $.PrivateBin.Model.reset();
+            PrivateBin.Model.reset();
         });
         });
 
 
-        jsc.property(
-            'returns the query string without separator, if any',
-            common.jscUrl(true, false),
-            jsc.tuple(new Array(16).fill(common.jscHexString)),
-            jsc.array(common.jscQueryString()),
-            jsc.array(common.jscQueryString()),
-            function (url, pasteId, queryStart, queryEnd) {
-                if (queryStart.length > 0) {
-                    queryStart.push('&');
-                }
-                if (queryEnd.length > 0) {
-                    queryEnd.unshift('&');
-                }
-                url.query = queryStart.concat(pasteId, queryEnd);
-                const pasteIdString = pasteId.join(''),
-                    clean           = jsdom('', {url: common.urlToString(url)});
-                const result = $.PrivateBin.Model.getPasteId();
-                $.PrivateBin.Model.reset();
-                clean();
-                return pasteIdString === result;
-            }
-        );
-        jsc.property(
-            'throws exception on empty query string',
-            common.jscUrl(true, false),
-            function (url) {
-                const clean = jsdom('', {url: common.urlToString(url)});
-                let result = false;
-                try {
-                    $.PrivateBin.Model.getPasteId();
+        it('returns the query string without separator, if any', () => {
+            fc.assert(fc.property(
+                common.fcUrl(true, false),
+                fc.array(common.fcHexString(), {minLength: 16, maxLength: 16}),
+                fc.array(common.fcQueryString()),
+                fc.array(common.fcQueryString()),
+                function (url, pasteId, queryStart, queryEnd) {
+                    if (queryStart.length > 0) {
+                        queryStart.push('&');
+                    }
+                    if (queryEnd.length > 0) {
+                        queryEnd.unshift('&');
+                    }
+                    url.query = queryStart.concat(pasteId, queryEnd);
+                    const pasteIdString = pasteId.join(''),
+                        clean           = globalThis.cleanup('', {url: common.urlToString(url)});
+                    const result = PrivateBin.Model.getPasteId();
+                    PrivateBin.Model.reset();
+                    clean();
+                    return pasteIdString === result;
                 }
                 }
-                catch(err) {
-                    result = true;
+            ));
+        });
+        it('throws exception on empty query string', () => {
+            fc.assert(fc.property(
+                common.fcUrl(true, false),
+                function (url) {
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)});
+                    let result = false;
+                    try {
+                        PrivateBin.Model.getPasteId();
+                    }
+                    catch(err) {
+                        result = true;
+                    }
+                    PrivateBin.Model.reset();
+                    clean();
+                    return result;
                 }
                 }
-                $.PrivateBin.Model.reset();
+            ));
+        });
+        it('throws exception on non-hexadecimal document IDs', () => {
+            const clean = globalThis.cleanup('', {
+                url: 'https://example.com/?gggggggggggggggg#key'
+            });
+            assert.throws(() => PrivateBin.Model.getPasteId());
+            PrivateBin.Model.reset();
+            clean();
+        });
+    });
+
+    describe('hasDeleteToken', function () {
+        it('only recognizes an exact query parameter name', () => {
+            const cases = [
+                ['https://example.com/?abcdefghijklmnop&deletetoken=secret', true],
+                ['https://example.com/?abcdefghijklmnop&redirect=deletetoken', false],
+                ['https://example.com/?abcdefghijklmnop&notdeletetoken=secret', false]
+            ];
+
+            cases.forEach(function (testCase) {
+                const clean = globalThis.cleanup('', {url: testCase[0]});
+                assert.strictEqual(PrivateBin.Model.hasDeleteToken(), testCase[1]);
                 clean();
                 clean();
-                return result;
-            }
-        );
+            });
+        });
     });
     });
 
 
     describe('getPasteKey', function () {
     describe('getPasteKey', function () {
         this.timeout(30000);
         this.timeout(30000);
         beforeEach(function () {
         beforeEach(function () {
-            $.PrivateBin.Model.reset();
+            PrivateBin.Model.reset();
         });
         });
 
 
-        jsc.property(
-            'throws exception on v1 URLs',
-            common.jscUrl(),
-            function (url) {
-                url.fragment = '0OIl'; // any non-base58 string
-                const clean = jsdom('', {url: common.urlToString(url)});
-                let result = false;
-                try {
-                    $.PrivateBin.Model.getPasteId();
+        it('throws exception on v1 URLs', () => {
+            fc.assert(fc.property(
+                common.fcUrl(),
+                function (url) {
+                    url.fragment = '0OIl'; // any non-base58 string
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)});
+                    let result = false;
+                    try {
+                        PrivateBin.Model.getPasteId();
+                    }
+                    catch(err) {
+                        result = true;
+                    }
+                    PrivateBin.Model.reset();
+                    clean();
+                    return result;
                 }
                 }
-                catch(err) {
-                    result = true;
+            ));
+        });
+        it('returns the fragment stripped of trailing query parts', () => {
+            fc.assert(fc.property(
+                common.fcUrl(),
+                fc.array(common.fcHashString()),
+                function (url, trail) {
+                    const fragment = url.fragment.padStart(32, '\u0000');
+                    url.fragment = PrivateBin.CryptTool.base58encode(fragment) + '&' + trail.join('');
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)}),
+                        result = PrivateBin.Model.getPasteKey();
+                    PrivateBin.Model.reset();
+                    clean();
+                    return fragment === result;
                 }
                 }
-                $.PrivateBin.Model.reset();
-                clean();
-                return result;
-            }
-        );
-        jsc.property(
-            'returns the fragment stripped of trailing query parts',
-            common.jscUrl(),
-            jsc.array(common.jscHashString()),
-            function (url, trail) {
-                const fragment = url.fragment.padStart(32, '\u0000');
-                url.fragment = $.PrivateBin.CryptTool.base58encode(fragment) + '&' + trail.join('');
-                const clean = jsdom('', {url: common.urlToString(url)}),
-                    result = $.PrivateBin.Model.getPasteKey();
-                $.PrivateBin.Model.reset();
-                clean();
-                return fragment === result;
-            }
-        );
-        jsc.property(
-            'returns the fragment of a v2 URL',
-            common.jscUrl(),
-            function (url) {
-                // base58 strips leading NULL bytes, so the string is padded with these if not found
-                const fragment = url.fragment.padStart(32, '\u0000');
-                url.fragment = $.PrivateBin.CryptTool.base58encode(fragment);
-                const clean = jsdom('', {url: common.urlToString(url)}),
-                    result = $.PrivateBin.Model.getPasteKey();
-                $.PrivateBin.Model.reset();
-                clean();
-                return fragment === result;
-            }
-        );
-        jsc.property(
-            'returns the v2 fragment stripped of trailing query parts',
-            common.jscUrl(),
-            jsc.array(common.jscHashString()),
-            function (url, trail) {
-                // base58 strips leading NULL bytes, so the string is padded with these if not found
-                const fragment = url.fragment.padStart(32, '\u0000');
-                url.fragment = $.PrivateBin.CryptTool.base58encode(fragment) + '&' + trail.join('');
-                const clean = jsdom('', {url: common.urlToString(url)}),
-                    result = $.PrivateBin.Model.getPasteKey();
-                $.PrivateBin.Model.reset();
-                clean();
-                return fragment === result;
-            }
-        );
-        jsc.property(
-            'throws exception on empty fragment of the URL',
-            common.jscUrl(false),
-            function (url) {
-                let clean = jsdom('', {url: common.urlToString(url)}),
-                    result = false;
-                try {
-                    $.PrivateBin.Model.getPasteKey();
+            ));
+        });
+        it('returns the fragment of a v2 URL', () => {
+            fc.assert(fc.property(
+                common.fcUrl(),
+                function (url) {
+                    // base58 strips leading NULL bytes, so the string is padded with these if not found
+                    const fragment = url.fragment.padStart(32, '\u0000');
+                    url.fragment = PrivateBin.CryptTool.base58encode(fragment);
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)}),
+                        result = PrivateBin.Model.getPasteKey();
+                    PrivateBin.Model.reset();
+                    clean();
+                    return fragment === result;
                 }
                 }
-                catch(err) {
-                    result = true;
+            ));
+        });
+        it('returns the v2 fragment stripped of trailing query parts', () => {
+            fc.assert(fc.property(
+                common.fcUrl(),
+                fc.array(common.fcHashString()),
+                function (url, trail) {
+                    // base58 strips leading NULL bytes, so the string is padded with these if not found
+                    const fragment = url.fragment.padStart(32, '\u0000');
+                    url.fragment = PrivateBin.CryptTool.base58encode(fragment) + '&' + trail.join('');
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)}),
+                        result = PrivateBin.Model.getPasteKey();
+                    PrivateBin.Model.reset();
+                    clean();
+                    return fragment === result;
                 }
                 }
-                $.PrivateBin.Model.reset();
-                clean();
-                return result;
-            }
-        );
+            ));
+        });
+        it('throws exception on empty fragment of the URL', () => {
+            fc.assert(fc.property(
+                common.fcUrl(false),
+                function (url) {
+                    let clean = globalThis.cleanup('', {url: common.urlToString(url)}),
+                        result = false;
+                    try {
+                        PrivateBin.Model.getPasteKey();
+                    }
+                    catch(err) {
+                        result = true;
+                    }
+                    PrivateBin.Model.reset();
+                    clean();
+                    return result;
+                }
+            ));
+        });
     });
     });
 
 
     describe('getTemplate', function () {
     describe('getTemplate', function () {
         beforeEach(function () {
         beforeEach(function () {
-            $.PrivateBin.Model.reset();
+            PrivateBin.Model.reset();
         });
         });
 
 
-        jsc.property(
-            'returns the contents of the element with id "[name]template"',
-            jsc.nearray(common.jscAlnumString()),
-            jsc.nearray(common.jscA2zString()),
-            jsc.nearray(common.jscAlnumString()),
-            function (id, element, value) {
-                id = id.join('');
-                element = element.join('');
-                value = value.join('').trim();
+        it('returns the contents of the element with id "[name]template"', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString(), {minLength: 1}),
+                fc.array(common.fcA2zString(), {minLength: 1}),
+                fc.array(common.fcAlnumString(), {minLength: 1}),
+                function (id, element, value) {
+                    id = id.join('');
+                    element = element.join('');
+                    value = value.join('').trim();
 
 
-                // <br>, <hr>, <img> and <wbr> tags can't contain strings,
-                // table tags can't be alone, so test with a <p> instead
-                if (['br', 'col', 'hr', 'img', 'tr', 'td', 'th', 'wbr'].indexOf(element) >= 0) {
-                    element = 'p';
-                }
+                    // <br>, <hr>, <img> and <wbr> tags can't contain strings,
+                    // table tags can't be alone, so test with a <p> instead
+                    if (['br', 'col', 'hr', 'img', 'tr', 'td', 'th', 'wbr'].indexOf(element) >= 0) {
+                        element = 'p';
+                    }
 
 
-                $('body').html(
-                    '<div id="templates"><' + element + ' id="' + id +
-                    'template">' + value + '</' + element + '></div>'
-                );
-                $.PrivateBin.Model.init();
-                var template = '<' + element + ' id="' + id + '">' + value +
-                    '</' + element + '>',
-                    result = $.PrivateBin.Model.getTemplate(id).wrap('<p/>').parent().html();
-                $.PrivateBin.Model.reset();
-                return template === result;
-            }
-        );
+                    document.body.innerHTML = (
+                        '<div id="templates"><' + element + ' id="' + id +
+                        'template">' + value + '</' + element + '></div>'
+                    );
+                    PrivateBin.Model.init();
+                    var template = '<' + element + ' id="' + id + '">' + value +
+                        '</' + element + '>',
+                        templateEl = PrivateBin.Model.getTemplate(id),
+                        wrapper = document.createElement('p');
+                    wrapper.appendChild(templateEl.cloneNode(true));
+                    var result = wrapper.innerHTML;
+                    PrivateBin.Model.reset();
+                    return template === result;
+                }
+            ));
+        });
     });
     });
 });
 });
-

+ 227 - 163
js/test/PasteStatus.js

@@ -1,5 +1,6 @@
 'use strict';
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 
 function urlStrings(schema, longUrl, shortUrl) {
 function urlStrings(schema, longUrl, shortUrl) {
     longUrl.schema = schema;
     longUrl.schema = schema;
@@ -22,21 +23,78 @@ describe('PasteStatus', function () {
     describe('createPasteNotification', function () {
     describe('createPasteNotification', function () {
         this.timeout(30000);
         this.timeout(30000);
 
 
-        jsc.property(
-            'creates a notification after a successful document upload',
-            common.jscUrl(),
-            common.jscUrl(false),
-            function (url1, url2) {
-                const expected1 = common.urlToString(url1).replace(/&(gt|lt)$/, '&$1a'),
-                    expected2 = common.urlToString(url2).replace(/&(gt|lt)$/, '&$1a'),
-                    clean = jsdom();
-                $('body').html('<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification(expected1, expected2);
-                const result1 = $('#pasteurl')[0].href,
-                    result2 = $('#deletelink')[0].href;
-                clean();
-                return result1 === expected1 && result2 === expected2;
+        it('creates a notification after a successful document upload', function () {
+            cleanup();
+            document.body.innerHTML = '<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div><div id="pastesuccess"></div>';
+            PrivateBin.PasteStatus.init();
+            const expected1 = 'https://example.com/long';
+            const expected2 = 'https://example.com/short';
+            PrivateBin.PasteStatus.createPasteNotification(expected1, expected2);
+
+            assert.strictEqual(document.getElementById('pasteurl').href, expected1);
+            assert.strictEqual(document.getElementById('deletelink').href, expected2);
+            assert.ok(!document.getElementById('pastesuccess').classList.contains('hidden'));
+        });
+
+        it('creates a notification after a successful document upload (jsc)', () => {
+            fc.assert(fc.asyncProperty(
+                common.fcUrl(),
+                common.fcUrl(false),
+                async function (url1, url2) {
+                    // sometimes the generator returns incomplete objects, bail out
+                    if (!url1 || !url1.address || !url2 || !url2.address) {
+                        return true;
+                    }
+                    const expected1 = common.urlToString(url1).replace(/&(gt|lt)$/, '&$1a'),
+                        expected2 = common.urlToString(url2).replace(/&(gt|lt)$/, '&$1a');
+                    globalThis.cleanup();
+                    document.body.innerHTML = '<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div><div id="pastesuccess"></div>';
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.createPasteNotification(expected1, expected2);
+
+                    assert.ok(!document.getElementById('pastesuccess').classList.contains('hidden'));
+
+                    const result2 = document.getElementById('deletelink').href;
+                    return document.getElementById('pasteurl').href === expected1 && result2 === expected2;
+                }
+            ));
+        });
+
+        it(
+            'shows Cmd hotkey hint on macOS',
+            function () {
+                cleanup();
+                Object.defineProperty(navigator, 'userAgent', {
+                    value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
+                    configurable: true
+                });
+                document.body.innerHTML = '<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div>';
+                PrivateBin.PasteStatus.init();
+                PrivateBin.PasteStatus.createPasteNotification('https://example.com/', 'https://example.com/delete');
+
+                const hotkey = document.querySelector('#copyhint kbd').textContent;
+
+                assert.strictEqual(hotkey, 'Cmd');
+            }
+        );
+
+        it(
+            'shows Ctrl hotkey hint on non-Mac platforms',
+            function () {
+                cleanup();
+
+                Object.defineProperty(navigator, 'userAgent', {
+                    value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+                    configurable: true
+                });
+                document.body.innerHTML = '<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div>';
+
+                PrivateBin.PasteStatus.init();
+                PrivateBin.PasteStatus.createPasteNotification('https://example.com/', 'https://example.com/delete');
+
+                const hotkey = document.querySelector('#copyhint kbd').textContent;
+
+                assert.strictEqual(hotkey, 'Ctrl');
             }
             }
         );
         );
     });
     });
@@ -44,181 +102,187 @@ describe('PasteStatus', function () {
     describe('extractUrl', function () {
     describe('extractUrl', function () {
         this.timeout(30000);
         this.timeout(30000);
 
 
-        jsc.property(
-            'extracts and updates IDN URLs found in given response',
-            common.jscSchemas(false),
-            'nestring',
-            common.jscUrl(),
-            function (schema, domain, url) {
-                domain = domain.replace(/\P{Letter}|[\u{AA}-\u{BA}]/gu, '').toLowerCase();
-                if (domain.length === 0) {
-                    domain = 'a';
+        it('extracts and updates IDN URLs found in given response', () => {
+            fc.assert(fc.property(
+                common.fcSchemas(false),
+                fc.string({minLength: 1}),
+                common.fcUrl(),
+                function (schema, domain, url) {
+                    domain = domain.replace(/\P{Letter}|[\u{AA}-\u{BA}]/gu, '').toLowerCase();
+                    if (domain.length === 0) {
+                        domain = 'a';
+                    }
+                    url.schema = schema;
+                    url.address.unshift('.');
+                    url.address = domain.split('').concat(url.address);
+                    const urlString = common.urlToString(url),
+                        expected = urlString.substring((schema + '://' + domain).length),
+                        clean = globalThis.cleanup();
+
+                    document.body.innerHTML = '<div><div id="pastelink"></div></div>';
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.createPasteNotification('', '');
+                    PrivateBin.PasteStatus.extractUrl(urlString);
+
+                    const result = document.getElementById('pasteurl').href;
+                    clean();
+
+                    return result.endsWith(expected) && (
+                        result.startsWith(schema + '://xn--') ||
+                        result.startsWith(schema + '://' + domain)
+                    );
                 }
                 }
-                url.schema = schema;
-                url.address.unshift('.');
-                url.address = domain.split('').concat(url.address);
-                const urlString = common.urlToString(url),
-                    expected = urlString.substring((schema + '://' + domain).length),
-                    clean = jsdom();
-
-                $('body').html('<div><div id="pastelink"></div></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification('', '');
-                $.PrivateBin.PasteStatus.extractUrl(urlString);
-
-                const result = $('#pasteurl')[0].href;
-                clean();
-
-                return result.endsWith(expected) && (
-                    result.startsWith(schema + '://xn--') ||
-                    result.startsWith(schema + '://' + domain)
-                );
-            }
-        );
+            ));
+        });
 
 
         // YOURLS API samples from: https://yourls.org/readme.html#API;apireturn
         // YOURLS API samples from: https://yourls.org/readme.html#API;apireturn
-        jsc.property(
-            'extracts and updates URLs found in YOURLS API JSON response',
-            common.jscSchemas(false),
-            common.jscUrl(),
-            common.jscUrl(false),
-            function (schema, longUrl, shortUrl) {
-                const [longUrlString, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
-                    yourlsResponse = {
-                        url: {
-                            keyword: longUrl.address.join(''),
-                            url: longUrlString,
+        it('extracts and updates URLs found in YOURLS API JSON response', () => {
+            fc.assert(fc.property(
+                common.fcSchemas(false),
+                common.fcUrl(),
+                common.fcUrl(false),
+                function (schema, longUrl, shortUrl) {
+                    const [longUrlString, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
+                        yourlsResponse = {
+                            url: {
+                                keyword: longUrl.address.join(''),
+                                url: longUrlString,
+                                title: 'example title',
+                                date: '2014-10-24 16:01:39',
+                                ip: '127.0.0.1'
+                            },
+                            status: 'success',
+                            message: longUrlString + ' added to database',
                             title: 'example title',
                             title: 'example title',
-                            date: '2014-10-24 16:01:39',
-                            ip: '127.0.0.1'
+                            shorturl: shortUrlString,
+                            statusCode: 200
                         },
                         },
-                        status: 'success',
-                        message: longUrlString + ' added to database',
-                        title: 'example title',
-                        shorturl: shortUrlString,
-                        statusCode: 200
-                    },
-                    clean = jsdom();
-
-                $('body').html('<div><div id="pastelink"></div></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification('', '');
-                $.PrivateBin.PasteStatus.extractUrl(JSON.stringify(yourlsResponse, undefined, 4));
-
-                const result = $('#pasteurl')[0].href;
-                clean();
-
-                return result === shortUrlString;
-            }
-        );
-        jsc.property(
-            'extracts and updates URLs found in YOURLS API XML response',
-            common.jscSchemas(false),
-            common.jscUrl(),
-            common.jscUrl(false),
-            function (schema, longUrl, shortUrl) {
-                const [longUrlString, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
-                    yourlsResponse = '<result>\n' +
-                        '    <keyword>' + longUrl.address.join('') + '</keyword>\n' +
-                        '    <shorturl>' + shortUrlString + '</shorturl>\n' +
-                        '    <longurl>' + longUrlString + '</longurl>\n' +
-                        '    <message>success</message>\n' +
-                        '    <statusCode>200</statusCode>\n' +
+                        clean = globalThis.cleanup();
+
+                    document.body.innerHTML = '<div><div id="pastelink"></div></div>';
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.createPasteNotification('', '');
+                    PrivateBin.PasteStatus.extractUrl(JSON.stringify(yourlsResponse, undefined, 4));
+
+                    const result = document.getElementById('pasteurl').href;
+                    clean();
+
+                    return result === shortUrlString;
+                }
+            ));
+        });
+        it('extracts and updates URLs found in YOURLS API XML response', () => {
+            fc.assert(fc.property(
+                common.fcSchemas(false),
+                common.fcUrl(),
+                common.fcUrl(false),
+                function (schema, longUrl, shortUrl) {
+                    const [longUrlString, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
+                        yourlsResponse = '<result>\n' +
+                            '    <keyword>' + longUrl.address.join('') + '</keyword>\n' +
+                            '    <shorturl>' + shortUrlString + '</shorturl>\n' +
+                            '    <longurl>' + longUrlString + '</longurl>\n' +
+                            '    <message>success</message>\n' +
+                            '    <statusCode>200</statusCode>\n' +
                         '</result>',
                         '</result>',
-                    clean = jsdom();
+                        clean = globalThis.cleanup();
 
 
-                $('body').html('<div><div id="pastelink"></div></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification('', '');
-                $.PrivateBin.PasteStatus.extractUrl(yourlsResponse);
+                    document.body.innerHTML = '<div><div id="pastelink"></div></div>';
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.createPasteNotification('', '');
+                    PrivateBin.PasteStatus.extractUrl(yourlsResponse);
 
 
-                const result = $('#pasteurl')[0].href;
-                clean();
+                    const result = document.getElementById('pasteurl').href;
+                    clean();
 
 
-                return result === shortUrlString;
-            }
-        );
-        jsc.property(
-            'extracts and updates URLs found in YOURLS proxy HTML response',
-            common.jscSchemas(false),
-            common.jscUrl(),
-            common.jscUrl(false),
-            function (schema, longUrl, shortUrl) {
-                const [_, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
-                    yourlsResponse = '<!DOCTYPE html>\n' +
-                        '<html lang="en">\n' +
-                        '\t<head>\n' +
-                        '\t\t<meta charset="utf-8" />\n' +
-                        '\t\t<meta name="robots" content="noindex" />\n' +
-                        '\t\t<meta name="google" content="notranslate">\n' +
-                        '\t\t<title>PrivateBin</title>\n' +
-                        '\t</head>\n' +
-                        '\t<body>\n' +
-                        '\t\t<p>Your document is <a id="pasteurl" href="' + shortUrlString + '">' + shortUrlString + '</a> <span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span></p>\n' +
-                        '\t</body>\n' +
+                    return result === shortUrlString;
+                }
+            ));
+        });
+        it('extracts and updates URLs found in YOURLS proxy HTML response', () => {
+            fc.assert(fc.property(
+                common.fcSchemas(false),
+                common.fcUrl(),
+                common.fcUrl(false),
+                function (schema, longUrl, shortUrl) {
+                    const [_, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
+                        yourlsResponse = '<!DOCTYPE html>\n' +
+                            '<html lang="en">\n' +
+                            '\t<head>\n' +
+                            '\t\t<meta charset="utf-8" />\n' +
+                            '\t\t<meta name="robots" content="noindex" />\n' +
+                            '\t\t<meta name="google" content="notranslate">\n' +
+                            '\t\t<title>PrivateBin</title>\n' +
+                            '\t</head>\n' +
+                            '\t<body>\n' +
+                            '\t\t<p>Your document is <a id="pasteurl" href="' + shortUrlString + '">' + shortUrlString + '</a> <span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span></p>\n' +
+                            '\t</body>\n' +
                         '</html>',
                         '</html>',
-                    clean = jsdom();
+                        clean = globalThis.cleanup();
 
 
-                $('body').html('<div><div id="pastelink"></div></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification('', '');
-                $.PrivateBin.PasteStatus.extractUrl(yourlsResponse);
+                    document.body.innerHTML = '<div><div id="pastelink"></div></div>';
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.createPasteNotification('', '');
+                    PrivateBin.PasteStatus.extractUrl(yourlsResponse);
 
 
-                const result = $('#pasteurl')[0].href;
-                clean();
+                    const result = document.getElementById('pasteurl').href;
+                    clean();
 
 
-                return result === shortUrlString;
-            }
-        );
+                    return result === shortUrlString;
+                }
+            ));
+        });
     });
     });
 
 
     describe('showRemainingTime', function () {
     describe('showRemainingTime', function () {
         this.timeout(30000);
         this.timeout(30000);
 
 
-        jsc.property(
-            'shows burn after reading message or remaining time',
-            'bool',
-            'nat',
-            common.jscUrl(),
-            function (burnafterreading, remainingTime, url) {
-                let clean = jsdom('', {url: common.urlToString(url)}),
-                    result;
-                $('body').html('<div id="remainingtime" class="hidden"></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.showRemainingTime($.PrivateBin.Helper.PasteFactory({
-                    'adata': [null, null, null, burnafterreading],
-                    'v': 2,
-                    'meta': {
-                        'time_to_live': remainingTime
+        it('shows burn after reading message or remaining time', () => {
+            fc.assert(fc.property(
+                fc.boolean(),
+                fc.nat(),
+                common.fcUrl(),
+                function (burnafterreading, remainingTime, url) {
+                    let clean = globalThis.cleanup('', {url: common.urlToString(url)}),
+                        result;
+                    document.body.innerHTML = '<div id="remainingtime" class="hidden"></div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.showRemainingTime(PrivateBin.Helper.PasteFactory({
+                        'adata': [null, null, null, burnafterreading],
+                        'v': 2,
+                        'meta': {
+                            'time_to_live': remainingTime
+                        }
+                    }));
+                    if (burnafterreading) {
+                        result = document.getElementById('remainingtime').classList.contains('foryoureyesonly') &&
+                                !document.getElementById('remainingtime').classList.contains('hidden');
+                    } else if (remainingTime) {
+                        result =!document.getElementById('remainingtime').classList.contains('foryoureyesonly') &&
+                                !document.getElementById('remainingtime').classList.contains('hidden');
+                    } else {
+                        result = document.getElementById('remainingtime').classList.contains('hidden') &&
+                                !document.getElementById('remainingtime').classList.contains('foryoureyesonly');
                     }
                     }
-                }));
-                if (burnafterreading) {
-                    result = $('#remainingtime').hasClass('foryoureyesonly') &&
-                            !$('#remainingtime').hasClass('hidden');
-                } else if (remainingTime) {
-                    result =!$('#remainingtime').hasClass('foryoureyesonly') &&
-                            !$('#remainingtime').hasClass('hidden');
-                } else {
-                    result = $('#remainingtime').hasClass('hidden') &&
-                            !$('#remainingtime').hasClass('foryoureyesonly');
+                    clean();
+                    return result;
                 }
                 }
-                clean();
-                return result;
-            }
-        );
+            ));
+        });
     });
     });
 
 
     describe('hideMessages', function () {
     describe('hideMessages', function () {
         it(
         it(
             'hides all messages',
             'hides all messages',
             function() {
             function() {
-                $('body').html(
+                document.body.innerHTML = (
                     '<div id="remainingtime"></div><div id="pastesuccess"></div>'
                     '<div id="remainingtime"></div><div id="pastesuccess"></div>'
                 );
                 );
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.hideMessages();
-                assert.ok($('#remainingtime').hasClass('hidden'));
-                assert.ok($('#pastesuccess').hasClass('hidden'));
+                PrivateBin.PasteStatus.init();
+                PrivateBin.PasteStatus.hideMessages();
+                assert.ok(document.getElementById('remainingtime').classList.contains('hidden'));
+                assert.ok(document.getElementById('pastesuccess').classList.contains('hidden'));
             }
             }
         );
         );
     });
     });

+ 147 - 104
js/test/PasteViewer.js

@@ -1,118 +1,161 @@
 'use strict';
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 
 describe('PasteViewer', function () {
 describe('PasteViewer', function () {
     describe('run, hide, getText, setText, getFormat, setFormat & isPrettyPrinted', function () {
     describe('run, hide, getText, setText, getFormat, setFormat & isPrettyPrinted', function () {
         this.timeout(30000);
         this.timeout(30000);
 
 
-        jsc.property(
-            'displays text according to format',
-            common.jscFormats(),
-            'nestring',
-            function (format, text) {
-                var clean = jsdom(),
-                    results = [];
-                $('body').html(
-                    '<div id="placeholder" class="hidden">+++ no document text ' +
-                    '+++</div><div id="prettymessage" class="hidden"><pre ' +
-                    'id="prettyprint" class="prettyprint linenums:1"></pre>' +
-                    '</div><div id="plaintext" class="hidden"></div>'
-                );
-                $.PrivateBin.PasteViewer.init();
-                $.PrivateBin.PasteViewer.setFormat(format);
-                $.PrivateBin.PasteViewer.setText('');
-                results.push(
-                    $('#placeholder').hasClass('hidden') &&
-                    $('#prettymessage').hasClass('hidden') &&
-                    $('#plaintext').hasClass('hidden') &&
-                    $.PrivateBin.PasteViewer.getFormat() === format &&
-                    $.PrivateBin.PasteViewer.getText() === ''
-                );
-                $.PrivateBin.PasteViewer.run();
-                results.push(
-                    !$('#placeholder').hasClass('hidden') &&
-                    $('#prettymessage').hasClass('hidden') &&
-                    $('#plaintext').hasClass('hidden')
-                );
-                $.PrivateBin.PasteViewer.hide();
-                results.push(
-                    $('#placeholder').hasClass('hidden') &&
-                    $('#prettymessage').hasClass('hidden') &&
-                    $('#plaintext').hasClass('hidden')
-                );
-                $.PrivateBin.PasteViewer.setText(text);
-                $.PrivateBin.PasteViewer.run();
-                results.push(
-                    $('#placeholder').hasClass('hidden') &&
-                    !$.PrivateBin.PasteViewer.isPrettyPrinted() &&
-                    $.PrivateBin.PasteViewer.getText() === text
-                );
-                if (format === 'markdown') {
+        beforeEach(() => {
+            document.body.innerHTML = (
+                '<div id="placeholder" class="hidden">+++ no document text +++</div>' +
+                '<div id="prettymessage" class="hidden"><pre id="prettyprint" class="prettyprint linenums:1"></pre></div>' +
+                '<div id="plaintext" class="hidden"></div>'
+            );
+        });
+
+        afterEach(() => {
+            globalThis.cleanup();
+        });
+
+        it('basic plaintext display works', function () {
+            PrivateBin.PasteViewer.init();
+            PrivateBin.PasteViewer.setFormat('plaintext');
+            PrivateBin.PasteViewer.setText('hello');
+            PrivateBin.PasteViewer.run();
+            assert.strictEqual(PrivateBin.PasteViewer.getText(), 'hello');
+            assert.ok(!document.getElementById('prettymessage').classList.contains('hidden'));
+        });
+
+        it('basic markdown display works', function () {
+            PrivateBin.PasteViewer.init();
+            PrivateBin.PasteViewer.setFormat('markdown');
+            PrivateBin.PasteViewer.setText('hello **bold**');
+            PrivateBin.PasteViewer.run();
+            assert.strictEqual(PrivateBin.PasteViewer.getText(), 'hello **bold**');
+            assert.ok(!document.getElementById('plaintext').classList.contains('hidden'));
+        });
+
+        it('initializes with empty text and shows nothing', () => {
+            fc.assert(fc.property(
+                common.fcFormats(),
+                function (format) {
+                    var results = [];
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText('');
                     results.push(
                     results.push(
-                        $('#prettymessage').hasClass('hidden') &&
-                        !$('#plaintext').hasClass('hidden')
+                        document.getElementById('placeholder').classList.contains('hidden') &&
+                        document.getElementById('prettymessage').classList.contains('hidden') &&
+                        document.getElementById('plaintext').classList.contains('hidden') &&
+                        PrivateBin.PasteViewer.getFormat() === format &&
+                        PrivateBin.PasteViewer.getText() === ''
                     );
                     );
-                } else {
+                    return results.every(element => element);
+                }
+            ));
+        });
+
+        it('when no text is given and view is rendered, it shows placeholder', () => {
+            fc.assert(fc.property(
+                common.fcFormats(),
+                function (format) {
+                    var results = [];
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText('');
+                    PrivateBin.PasteViewer.run();
                     results.push(
                     results.push(
-                        !$('#prettymessage').hasClass('hidden') &&
-                        $('#plaintext').hasClass('hidden')
+                        !document.getElementById('placeholder').classList.contains('hidden') &&
+                        document.getElementById('prettymessage').classList.contains('hidden') &&
+                        document.getElementById('plaintext').classList.contains('hidden')
                     );
                     );
+                    return results.every(element => element);
                 }
                 }
-                clean();
-                return results.every(element => element);
-            }
-        );
+            ));
+        });
 
 
-        jsc.property(
-            'sanitizes XSS',
-            common.jscFormats(),
-            'string',
-            // @see    {@link https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet}
-            jsc.elements([
-                '<PLAINTEXT>',
-                '></SCRIPT>">\'><SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>',
-                '\'\';!--"<XSS>=&{()}',
-                '<SCRIPT SRC=http://example.com/xss.js></SCRIPT>',
-                '\'">><marquee><img src=x onerror=confirm(1)></marquee>">' +
-                '</plaintext\\></|\\><plaintext/onmouseover=prompt(1)>' +
-                '<script>prompt(1)</script>@gmail.com<isindex formaction=java' +
-                // obfuscate script URL from eslint rule: no-script-url
-                'script:alert(/XSS/) type=submit>\'-->"></script>' +
-                '<script>alert(document.cookie)</script>"><img/id="confirm' +
-                '&lpar;1)"/alt="/"src="/"onerror=eval(id)>\'">',
-                '<IMG SRC="javascript:alert(\'XSS\');">',
-                '<IMG SRC=javascript:alert(\'XSS\')>',
-                '<IMG SRC=JaVaScRiPt:alert(\'XSS\')>',
-                '<IMG SRC=javascript:alert(&quot;XSS&quot;)>',
-                '<IMG SRC=`javascript:alert("RSnake says, \'XSS\'")`>',
-                '<a onmouseover="alert(document.cookie)">xxs link</a>',
-                '<a onmouseover=alert(document.cookie)>xxs link</a>',
-                '<IMG """><SCRIPT>alert("XSS")</SCRIPT>">',
-                '<IMG SRC=javascript:alert(String.fromCharCode(88,83,83))>',
-                '<IMG STYLE="xss:expr/*XSS*/ession(alert(\'XSS\'))">',
-                '<FRAMESET><FRAME SRC="javascript:alert(\'XSS\');"></FRAMESET>',
-                '<TABLE BACKGROUND="javascript:alert(\'XSS\')">',
-                '<TABLE><TD BACKGROUND="javascript:alert(\'XSS\')">',
-                '<SCRIPT>document.write("<SCRI");</SCRIPT>PT SRC="httx://xss.rocks/xss.js"></SCRIPT>'
-            ]),
-            'string',
-            function (format, prefix, xss, suffix) {
-                var clean = jsdom(),
-                    text = prefix + xss + suffix;
-                $('body').html(
-                    '<div id="placeholder" class="hidden">+++ no document text ' +
-                    '+++</div><div id="prettymessage" class="hidden"><pre ' +
-                    'id="prettyprint" class="prettyprint linenums:1"></pre>' +
-                    '</div><div id="plaintext" class="hidden"></div>'
-                );
-                $.PrivateBin.PasteViewer.init();
-                $.PrivateBin.PasteViewer.setFormat(format);
-                $.PrivateBin.PasteViewer.setText(text);
-                $.PrivateBin.PasteViewer.run();
-                var result = $('body').html().indexOf(xss) === -1;
-                clean();
-                return result;
-            }
-        );
+        it('displays text according to format', () => {
+            fc.assert(fc.property(
+                common.fcFormats(),
+                fc.string({minLength: 1}),
+                function (format, text) {
+                    var results = [];
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText(text);
+                    PrivateBin.PasteViewer.run();
+                    results.push(
+                        document.getElementById('placeholder').classList.contains('hidden') &&
+                        !PrivateBin.PasteViewer.isPrettyPrinted() &&
+                        PrivateBin.PasteViewer.getText() === text
+                    );
+                    if (format === 'markdown') {
+                        results.push(
+                            document.getElementById('prettymessage').classList.contains('hidden') &&
+                            !document.getElementById('plaintext').classList.contains('hidden')
+                        );
+                    } else {
+                        results.push(
+                            !document.getElementById('prettymessage').classList.contains('hidden') &&
+                            document.getElementById('plaintext').classList.contains('hidden')
+                        );
+                    }
+
+                    return results.every(element => element);
+                }
+            ));
+        });
+
+        it('sanitizes XSS', () => {
+            fc.assert(fc.property(
+                common.fcFormats(),
+                fc.string(),
+                fc.constantFrom(
+                    '<PLAINTEXT>',
+                    '></SCRIPT>">\'><SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>',
+                    '\'\';!--"<XSS>=&{()}',
+                    '<SCRIPT SRC=http://example.com/xss.js></SCRIPT>',
+                    '\'">><marquee><img src=x onerror=confirm(1)></marquee>">' +
+                    '</plaintext\\></|\\><plaintext/onmouseover=prompt(1)>' +
+                    '<script>prompt(1)</script>@gmail.com<isindex formaction=java' +
+                    // obfuscate script URL from eslint rule: no-script-url
+                    'script:alert(/XSS/) type=submit>\'-->"></script>' +
+                    '<script>alert(document.cookie)</script>"><img/id="confirm' +
+                    '&lpar;1)"/alt="/"src="/"onerror=eval(id)>\'">',
+                    '<IMG SRC="javascript:alert(\'XSS\');">',
+                    '<IMG SRC=javascript:alert(\'XSS\')>',
+                    '<IMG SRC=JaVaScRiPt:alert(\'XSS\')>',
+                    '<IMG SRC=javascript:alert(&quot;XSS&quot;)>',
+                    '<IMG SRC=`javascript:alert("RSnake says, \'XSS\'")`>',
+                    '<a onmouseover="alert(document.cookie)">xxs link</a>',
+                    '<a onmouseover=alert(document.cookie)>xxs link</a>',
+                    '<IMG """><SCRIPT>alert("XSS")</SCRIPT>">',
+                    '<IMG SRC=javascript:alert(String.fromCharCode(88,83,83))>',
+                    '<IMG STYLE="xss:expr/*XSS*/ession(alert(\'XSS\'))">',
+                    '<FRAMESET><FRAME SRC="javascript:alert(\'XSS\');"></FRAMESET>',
+                    '<TABLE BACKGROUND="javascript:alert(\'XSS\')">',
+                    '<TABLE><TD BACKGROUND="javascript:alert(\'XSS\')">',
+                    '<SCRIPT>document.write("<SCRI");</SCRIPT>PT SRC="httx://xss.rocks/xss.js"></SCRIPT>'
+                ),
+                fc.string(),
+                function (format, prefix, xss, suffix) {
+                    var text = prefix + xss + suffix;
+                    document.body.innerHTML = (
+                        '<div id="placeholder" class="hidden">+++ no document text ' +
+                        '+++</div><div id="prettymessage" class="hidden"><pre ' +
+                        'id="prettyprint" class="prettyprint linenums:1"></pre>' +
+                        '</div><div id="plaintext" class="hidden"></div>'
+                    );
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText(text);
+                    PrivateBin.PasteViewer.run();
+                    var result = document.body.innerHTML.indexOf(xss) === -1;
+                    globalThis.cleanup();
+                    return result;
+                }
+            ));
+        });
     });
     });
 });
 });

+ 78 - 32
js/test/Prompt.js

@@ -1,41 +1,87 @@
 'use strict';
 'use strict';
 require('../common');
 require('../common');
+const fc = require('fast-check');
 
 
 describe('Prompt', function () {
 describe('Prompt', function () {
     describe('requestPassword & getPassword', function () {
     describe('requestPassword & getPassword', function () {
         this.timeout(30000);
         this.timeout(30000);
 
 
-        jsc.property(
-            'returns the password fed into the dialog',
-            'string',
-            function (password) {
-                password = password.replace(/\r+|\n+/g, '');
-                /* const clean = */ jsdom('', {url: 'ftp://example.com/?0000000000000000'});
-                $('body').html(
-                    '<div id="passwordmodal" class="modal fade" role="dialog">' +
-                    '<div class="modal-dialog"><div class="modal-content">' +
-                    '<div class="modal-body"><form id="passwordform" role="form">' +
-                    '<div class="form-group"><input id="passworddecrypt" ' +
-                    'type="password" class="form-control" placeholder="Enter ' +
-                    'password"></div><button type="submit">Decrypt</button>' +
-                    '</form></div></div></div></div>'
-                );
-                $.PrivateBin.Model.reset();
-                $.PrivateBin.Model.init();
-                // eslint-disable-next-line global-require
-                global.bootstrap = require('../bootstrap-5.3.8');
-                $.PrivateBin.Prompt.init();
-                $.PrivateBin.Prompt.requestPassword();
-                $('#passworddecrypt').val(password);
-                // TODO triggers error messages in current jsDOM version, find better solution
-                //$('#passwordform').submit();
-                //var result = $.PrivateBin.Prompt.getPassword();
-                const result = $('#passworddecrypt').val();
-                $.PrivateBin.Model.reset();
-                // TODO triggers error messages in jsDOM since version 11
-                //clean();
-                return result === password;
-            }
-        );
+        it('returns the password fed into the dialog', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (password) {
+                    password = password.replace(/\r+|\n+/g, '');
+                    const clean = globalThis.cleanup('', {url: 'ftp://example.com/?0000000000000000'});
+                    document.body.innerHTML = `
+                        <div id="passwordmodal" class="modal fade" role="dialog">
+                            <div class="modal-dialog">
+                                <div class="modal-content">
+                                    <div class="modal-body">
+                                        <form id="passwordform" role="form">
+                                            <div class="form-group">
+                                                <input id="passworddecrypt" type="password"
+                                                    class="form-control" placeholder="Enter password">
+                                            </div>
+                                            <button type="submit">Decrypt</button>
+                                        </form>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                        <nav class="navbar navbar-inverse navbar-static-top">
+                            <div id="navbar" class="navbar-collapse collapse">
+                            <ul class="nav navbar-nav"><li><button id="newbutton" type="button" class="hidden btn btn-warning navbar-btn">
+                            <span class="glyphicon glyphicon-file" aria-hidden="true">
+                                </span> New</button><button id="clonebutton" type="button" class="hidden btn btn-warning navbar-btn">
+                            <span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone</button>
+                            <button id="rawtextbutton" type="button" class="hidden btn btn-warning navbar-btn">
+                            <span class="glyphicon glyphicon-text-background" aria-hidden="true"></span> Raw text</button>
+                            <button id="downloadtextbutton" type="button" class="hidden btn btn-default navbar-btn"></button>
+                            <button id="qrcodelink" type="button" data-toggle="modal" data-target="#qrcodemodal" class="hidden btn btn-warning navbar-btn"/>
+                            <span class="glyphicon glyphicon-qrcode" aria-hidden="true"></span> QR code</button></li></ul></div>
+                        </nav>
+                        <div id="errormessage"></div>
+                        <div id="loadingindicator"></div>
+                        <div id="status"></div>
+                    `;
+
+                    // Initialize the Prompt module to set up event listeners
+                    PrivateBin.Alert.init();
+                    PrivateBin.TopNav.init();
+                    PrivateBin.Prompt.init();
+                    PrivateBin.Prompt.requestPassword();
+
+                    // mock request response
+                    const originalFetch = globalThis.fetch;
+                    globalThis.fetch = function (url) {
+                        assert.strictEqual(url, 'ftp://example.com/?pasteid=0000000000000000');
+                        return Promise.resolve({
+                            ok: true,
+                            status: 200,
+                            statusText: 'OK',
+                            json: function () {
+                                return Promise.resolve({});
+                            }
+                        });
+                    };
+
+                    // Simulate user input
+                    const passwordInput = document.getElementById('passworddecrypt');
+                    passwordInput.value = password;
+
+                    // Simulate form submission to trigger password capture
+                    const passwordForm = document.getElementById('passwordform');
+                    /** {@type SubmitEvent} */
+                    const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
+                    passwordForm.dispatchEvent(submitEvent);
+
+                    // Verify that getPassword returns the submitted password
+                    const result = PrivateBin.Prompt.getPassword();
+                    globalThis.fetch = originalFetch;
+                    clean();
+                    return result === password;
+                }
+            ));
+        });
     });
     });
 });
 });

+ 23 - 15
js/test/ServerInteraction.js

@@ -1,5 +1,6 @@
 'use strict';
 'use strict';
-require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 
 describe('ServerInteraction', function () {
 describe('ServerInteraction', function () {
     describe('prepare', function () {
     describe('prepare', function () {
@@ -8,33 +9,40 @@ describe('ServerInteraction', function () {
             await new Promise(resolve => setTimeout(resolve, 1900));
             await new Promise(resolve => setTimeout(resolve, 1900));
         });
         });
         this.timeout(30000);
         this.timeout(30000);
-        it('can prepare an encrypted document', function () {
-            jsc.assert(jsc.forall(
-                'string',
-                'string',
-                'string',
+        it('can prepare an encrypted document', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.string(),
+                fc.string(),
+                fc.string(),
                 async function (key, password, message) {
                 async function (key, password, message) {
                     // pause to let async functions conclude
                     // pause to let async functions conclude
                     await new Promise(resolve => setTimeout(resolve, 300));
                     await new Promise(resolve => setTimeout(resolve, 300));
-                    let clean = jsdom();
-                    window.crypto = new WebCrypto();
+                    let clean = globalThis.cleanup();
+                    Object.defineProperty(window, 'crypto', {
+                        value: new WebCrypto(),
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
+                    });
+                    global.atob = common.atob;
+                    global.btoa = common.btoa;
                     message = message.trim();
                     message = message.trim();
 
 
-                    $.PrivateBin.ServerInteraction.prepare();
-                    $.PrivateBin.ServerInteraction.setCryptParameters(password, key);
-                    $.PrivateBin.ServerInteraction.setUnencryptedData('adata', [
+                    PrivateBin.ServerInteraction.prepare();
+                    PrivateBin.ServerInteraction.setCryptParameters(password, key);
+                    PrivateBin.ServerInteraction.setUnencryptedData('adata', [
                         // encryption parameters defined by CryptTool, format, discussion, burn after reading
                         // encryption parameters defined by CryptTool, format, discussion, burn after reading
                         null, 'plaintext', 0, 0
                         null, 'plaintext', 0, 0
                     ]);
                     ]);
-                    $.PrivateBin.ServerInteraction.setUnencryptedData('meta', {'expire': '5min'});
-                    await $.PrivateBin.ServerInteraction.setCipherMessage({'paste': message});
-                    //console.log($.PrivateBin.ServerInteraction.getData());
+                    PrivateBin.ServerInteraction.setUnencryptedData('meta', {'expire': '5min'});
+                    await PrivateBin.ServerInteraction.setCipherMessage({'paste': message});
+                    //console.log(PrivateBin.ServerInteraction.getData());
                     clean();
                     clean();
                     // TODO currently not testing anything and just used to generate v2 pastes for starting development of server side v2 implementation
                     // TODO currently not testing anything and just used to generate v2 pastes for starting development of server side v2 implementation
                     return true;
                     return true;
                 }
                 }
             ),
             ),
-            {tests: 3});
+            {numRuns: 3});
         });
         });
     });
     });
 });
 });

+ 243 - 250
js/test/TopNav.js

@@ -1,5 +1,13 @@
 'use strict';
 'use strict';
 require('../common');
 require('../common');
+const fc = require('fast-check');
+
+function query(selector) {
+    if (selector.startsWith('#')) {
+        return document.getElementById(selector.slice(1));
+    }
+    return document.querySelector(selector);
+}
 
 
 describe('TopNav', function () {
 describe('TopNav', function () {
     describe('showViewButtons & hideViewButtons', function () {
     describe('showViewButtons & hideViewButtons', function () {
@@ -11,45 +19,39 @@ describe('TopNav', function () {
             'displays & hides navigation elements for viewing an existing document',
             'displays & hides navigation elements for viewing an existing document',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
-                    '<nav class="navbar navbar-inverse navbar-static-top">' +
-                    '<div id="navbar" class="navbar-collapse collapse"><ul ' +
-                    'class="nav navbar-nav"><li><button id="newbutton" ' +
-                    'type="button" class="hidden btn btn-warning navbar-btn">' +
-                    '<span class="glyphicon glyphicon-file" aria-hidden="true">' +
-                    '</span> New</button><button id="clonebutton" type="button"' +
-                    ' class="hidden btn btn-warning navbar-btn">' +
-                    '<span class="glyphicon glyphicon-duplicate" ' +
-                    'aria-hidden="true"></span> Clone</button><button ' +
-                    'id="rawtextbutton" type="button" class="hidden btn ' +
-                    'btn-warning navbar-btn"><span class="glyphicon ' +
-                    'glyphicon-text-background" aria-hidden="true"></span> ' +
-                    'Raw text</button><button id="qrcodelink" type="button" ' +
-                    'data-toggle="modal" data-target="#qrcodemodal" ' +
-                    'class="hidden btn btn-warning navbar-btn"><span ' +
-                    'class="glyphicon glyphicon-qrcode" aria-hidden="true">' +
-                    '</span> QR code</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
-                results.push(
-                    $('#newbutton').hasClass('hidden') &&
-                    $('#clonebutton').hasClass('hidden') &&
-                    $('#rawtextbutton').hasClass('hidden') &&
-                    $('#qrcodelink').hasClass('hidden')
-                );
-                $.PrivateBin.TopNav.showViewButtons();
-                results.push(
-                    !$('#newbutton').hasClass('hidden') &&
-                    !$('#clonebutton').hasClass('hidden') &&
-                    !$('#rawtextbutton').hasClass('hidden') &&
-                    !$('#qrcodelink').hasClass('hidden')
-                );
-                $.PrivateBin.TopNav.hideViewButtons();
-                results.push(
-                    $('#newbutton').hasClass('hidden') &&
-                    $('#clonebutton').hasClass('hidden') &&
-                    $('#rawtextbutton').hasClass('hidden') &&
-                    $('#qrcodelink').hasClass('hidden')
+                document.documentElement.innerHTML =
+                    `<nav class="navbar navbar-inverse navbar-static-top">
+                    <div id="navbar" class="navbar-collapse collapse">
+                    <ul class="nav navbar-nav"><li><button id="newbutton" type="button" class="hidden btn btn-warning navbar-btn">
+                    <span class="glyphicon glyphicon-file" aria-hidden="true">
+                        </span> New</button><button id="clonebutton" type="button" class="hidden btn btn-warning navbar-btn">
+                    <span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone</button>
+                    <button id="rawtextbutton" type="button" class="hidden btn btn-warning navbar-btn">
+                    <span class="glyphicon glyphicon-text-background" aria-hidden="true"></span> Raw text</button>
+                    <button id="downloadtextbutton" type="button" class="hidden btn btn-default navbar-btn"></button>
+                    <button id="qrcodelink" type="button" data-toggle="modal" data-target="#qrcodemodal" class="hidden btn btn-warning navbar-btn"/>
+                    <span class="glyphicon glyphicon-qrcode" aria-hidden="true"></span> QR code</button></li></ul></div>
+                    </nav>`;
+                PrivateBin.TopNav.init();
+                results.push(
+                    query('#newbutton').classList.contains('hidden') &&
+                    query('#clonebutton').classList.contains('hidden') &&
+                    query('#rawtextbutton').classList.contains('hidden') &&
+                    query('#qrcodelink').classList.contains('hidden')
+                );
+                PrivateBin.TopNav.showViewButtons();
+                results.push(
+                    !query('#newbutton').classList.contains('hidden') &&
+                    !query('#clonebutton').classList.contains('hidden') &&
+                    !query('#rawtextbutton').classList.contains('hidden') &&
+                    !query('#qrcodelink').classList.contains('hidden')
+                );
+                PrivateBin.TopNav.hideViewButtons();
+                results.push(
+                    query('#newbutton').classList.contains('hidden') &&
+                    query('#clonebutton').classList.contains('hidden') &&
+                    query('#rawtextbutton').classList.contains('hidden') &&
+                    query('#qrcodelink').classList.contains('hidden')
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -70,7 +72,7 @@ describe('TopNav', function () {
             'displays & hides navigation elements for creating a document',
             'displays & hides navigation elements for creating a document',
             function () { // eslint-disable-line complexity
             function () { // eslint-disable-line complexity
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li><button id="newbutton" ' +
                     '<nav><div id="navbar"><ul><li><button id="newbutton" ' +
                     'type="button" class="hidden">New</button></li><li><a ' +
                     'type="button" class="hidden">New</button></li><li><a ' +
                     'id="expiration" href="#" class="hidden">Expiration</a>' +
                     'id="expiration" href="#" class="hidden">Expiration</a>' +
@@ -81,40 +83,39 @@ describe('TopNav', function () {
                     '<li id="attach" class="hidden">Attach a file</li><li>' +
                     '<li id="attach" class="hidden">Attach a file</li><li>' +
                     '<a id="formatter" href="#" class="hidden">Format</a>' +
                     '<a id="formatter" href="#" class="hidden">Format</a>' +
                     '</li><li><button id="sendbutton" type="button" ' +
                     '</li><li><button id="sendbutton" type="button" ' +
-                    'class="hidden">Create</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
-                results.push(
-                    $('#sendbutton').hasClass('hidden') &&
-                    $('#expiration').hasClass('hidden') &&
-                    $('#formatter').hasClass('hidden') &&
-                    $('#burnafterreadingoption').hasClass('hidden') &&
-                    $('#opendiscussionoption').hasClass('hidden') &&
-                    $('#newbutton').hasClass('hidden') &&
-                    $('#password').hasClass('hidden') &&
-                    $('#attach').hasClass('hidden')
-                );
-                $.PrivateBin.TopNav.showCreateButtons();
-                results.push(
-                    !$('#sendbutton').hasClass('hidden') &&
-                    !$('#expiration').hasClass('hidden') &&
-                    !$('#formatter').hasClass('hidden') &&
-                    !$('#burnafterreadingoption').hasClass('hidden') &&
-                    !$('#opendiscussionoption').hasClass('hidden') &&
-                    !$('#newbutton').hasClass('hidden') &&
-                    !$('#password').hasClass('hidden') &&
-                    !$('#attach').hasClass('hidden')
-                );
-                $.PrivateBin.TopNav.hideCreateButtons();
-                results.push(
-                    $('#sendbutton').hasClass('hidden') &&
-                    $('#expiration').hasClass('hidden') &&
-                    $('#formatter').hasClass('hidden') &&
-                    $('#burnafterreadingoption').hasClass('hidden') &&
-                    $('#opendiscussionoption').hasClass('hidden') &&
-                    $('#newbutton').hasClass('hidden') &&
-                    $('#password').hasClass('hidden') &&
-                    $('#attach').hasClass('hidden')
+                    'class="hidden">Create</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
+                results.push(
+                    query('#sendbutton').classList.contains('hidden') &&
+                    query('#expiration').classList.contains('hidden') &&
+                    query('#formatter').classList.contains('hidden') &&
+                    query('#burnafterreadingoption').classList.contains('hidden') &&
+                    query('#opendiscussionoption').classList.contains('hidden') &&
+                    query('#newbutton').classList.contains('hidden') &&
+                    query('#password').classList.contains('hidden') &&
+                    query('#attach').classList.contains('hidden')
+                );
+                PrivateBin.TopNav.showCreateButtons();
+                results.push(
+                    !query('#sendbutton').classList.contains('hidden') &&
+                    !query('#expiration').classList.contains('hidden') &&
+                    !query('#formatter').classList.contains('hidden') &&
+                    !query('#burnafterreadingoption').classList.contains('hidden') &&
+                    !query('#opendiscussionoption').classList.contains('hidden') &&
+                    !query('#newbutton').classList.contains('hidden') &&
+                    !query('#password').classList.contains('hidden') &&
+                    !query('#attach').classList.contains('hidden')
+                );
+                PrivateBin.TopNav.hideCreateButtons();
+                results.push(
+                    query('#sendbutton').classList.contains('hidden') &&
+                    query('#expiration').classList.contains('hidden') &&
+                    query('#formatter').classList.contains('hidden') &&
+                    query('#burnafterreadingoption').classList.contains('hidden') &&
+                    query('#opendiscussionoption').classList.contains('hidden') &&
+                    query('#newbutton').classList.contains('hidden') &&
+                    query('#password').classList.contains('hidden') &&
+                    query('#attach').classList.contains('hidden')
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -135,17 +136,16 @@ describe('TopNav', function () {
             'displays the button for creating a document',
             'displays the button for creating a document',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li><button id="newbutton" type=' +
                     '<nav><div id="navbar"><ul><li><button id="newbutton" type=' +
-                    '"button" class="hidden">New</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '"button" class="hidden">New</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    $('#newbutton').hasClass('hidden')
+                    query('#newbutton').classList.contains('hidden')
                 );
                 );
-                $.PrivateBin.TopNav.showNewPasteButton();
+                PrivateBin.TopNav.showNewPasteButton();
                 results.push(
                 results.push(
-                    !$('#newbutton').hasClass('hidden')
+                    !query('#newbutton').classList.contains('hidden')
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -166,19 +166,18 @@ describe('TopNav', function () {
             'hides the button for cloning a document',
             'hides the button for cloning a document',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li><button id="clonebutton" ' +
                     '<nav><div id="navbar"><ul><li><button id="clonebutton" ' +
                     'type="button" class="btn btn-warning navbar-btn">' +
                     'type="button" class="btn btn-warning navbar-btn">' +
                     '<span class="glyphicon glyphicon-duplicate" aria-hidden=' +
                     '<span class="glyphicon glyphicon-duplicate" aria-hidden=' +
-                    '"true"></span> Clone</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '"true"></span> Clone</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    !$('#clonebutton').hasClass('hidden')
+                    !query('#clonebutton').classList.contains('hidden')
                 );
                 );
-                $.PrivateBin.TopNav.hideCloneButton();
+                PrivateBin.TopNav.hideCloneButton();
                 results.push(
                 results.push(
-                    $('#clonebutton').hasClass('hidden')
+                    query('#clonebutton').classList.contains('hidden')
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -199,20 +198,19 @@ describe('TopNav', function () {
             'hides the raw text button',
             'hides the raw text button',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li><button ' +
                     '<nav><div id="navbar"><ul><li><button ' +
                     'id="rawtextbutton" type="button" class="btn ' +
                     'id="rawtextbutton" type="button" class="btn ' +
                     'btn-warning navbar-btn"><span class="glyphicon ' +
                     'btn-warning navbar-btn"><span class="glyphicon ' +
                     'glyphicon-text-background" aria-hidden="true"></span> ' +
                     'glyphicon-text-background" aria-hidden="true"></span> ' +
-                    'Raw text</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Raw text</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    !$('#rawtextbutton').hasClass('hidden')
+                    !query('#rawtextbutton').classList.contains('hidden')
                 );
                 );
-                $.PrivateBin.TopNav.hideRawButton();
+                PrivateBin.TopNav.hideRawButton();
                 results.push(
                 results.push(
-                    $('#rawtextbutton').hasClass('hidden')
+                    query('#rawtextbutton').classList.contains('hidden')
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -233,7 +231,7 @@ describe('TopNav', function () {
             'hides the file attachment selection button',
             'hides the file attachment selection button',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
@@ -242,15 +240,14 @@ describe('TopNav', function () {
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
-                    '</li></ul></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '</li></ul></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    !$('#filewrap').hasClass('hidden')
+                    !query('#filewrap').classList.contains('hidden')
                 );
                 );
-                $.PrivateBin.TopNav.hideFileSelector();
+                PrivateBin.TopNav.hideFileSelector();
                 results.push(
                 results.push(
-                    $('#filewrap').hasClass('hidden')
+                    query('#filewrap').classList.contains('hidden')
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -271,7 +268,7 @@ describe('TopNav', function () {
             'display the custom file attachment',
             'display the custom file attachment',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
@@ -280,15 +277,14 @@ describe('TopNav', function () {
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
-                    '</li></ul></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '</li></ul></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    $('#customattachment').hasClass('hidden')
+                    query('#customattachment').classList.contains('hidden')
                 );
                 );
-                $.PrivateBin.TopNav.showCustomAttachment();
+                PrivateBin.TopNav.showCustomAttachment();
                 results.push(
                 results.push(
-                    !$('#customattachment').hasClass('hidden')
+                    !query('#customattachment').classList.contains('hidden')
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -308,9 +304,8 @@ describe('TopNav', function () {
         it(
         it(
             'collapses the navigation when displayed on a small screen',
             'collapses the navigation when displayed on a small screen',
             function () {
             function () {
-                const clean = jsdom();
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div class="navbar-header"><button type="button" ' +
                     '<nav><div class="navbar-header"><button type="button" ' +
                     'class="navbar-toggle collapsed" data-toggle="collapse" ' +
                     'class="navbar-toggle collapsed" data-toggle="collapse" ' +
                     'data-target="#navbar" aria-expanded="false" aria-controls' +
                     'data-target="#navbar" aria-expanded="false" aria-controls' +
@@ -318,34 +313,33 @@ describe('TopNav', function () {
                     'navbar-brand" href=""><img alt="PrivateBin" ' +
                     'navbar-brand" href=""><img alt="PrivateBin" ' +
                     'src="img/icon.svg" width="38" /></a></div><div ' +
                     'src="img/icon.svg" width="38" /></a></div><div ' +
                     'id="navbar"><ul><li><button id="newbutton" type=' +
                     'id="navbar"><ul><li><button id="newbutton" type=' +
-                    '"button" class="hidden">New</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '"button" class="hidden">New</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    $('.navbar-toggle').hasClass('collapsed') &&
-                    $('#navbar').attr('aria-expanded') !== 'true'
+                    query('.navbar-toggle').classList.contains('collapsed') &&
+                    query('#navbar').getAttribute('aria-expanded') !== 'true'
                 );
                 );
-                $.PrivateBin.TopNav.collapseBar();
+                PrivateBin.TopNav.collapseBar();
                 results.push(
                 results.push(
-                    $('.navbar-toggle').hasClass('collapsed') &&
-                    $('#navbar').attr('aria-expanded') !== 'true'
+                    query('.navbar-toggle').classList.contains('collapsed') &&
+                    query('#navbar').getAttribute('aria-expanded') !== 'true'
                 );
                 );
                 /*
                 /*
                 with the upgrade for bootstrap-3.3.7.js to bootstrap-3.4.1.js
                 with the upgrade for bootstrap-3.3.7.js to bootstrap-3.4.1.js
                 the mobile interface detection changed to check if the
                 the mobile interface detection changed to check if the
                 ontouchstart event exists, which broke this section of the test
                 ontouchstart event exists, which broke this section of the test
-                $('.navbar-toggle').trigger('click');
+                const toggleBtn = document.querySelector('.navbar-toggle');
+                toggleBtn.dispatchEvent(new MouseEvent('click'));
                 results.push(
                 results.push(
-                    !$('.navbar-toggle').hasClass('collapsed') &&
-                    $('#navbar').attr('aria-expanded') == 'true'
+                    !toggleBtn.classList.contains('collapsed') &&
+                    document.getElementById('navbar').getAttribute('aria-expanded') == 'true'
                 );
                 );
-                $.PrivateBin.TopNav.collapseBar();
+                PrivateBin.TopNav.collapseBar();
                 results.push(
                 results.push(
-                    $('.navbar-toggle').hasClass('collapsed') &&
-                    $('#navbar').attr('aria-expanded') == 'false'
+                    document.querySelector('.navbar-toggle').classList.contains('collapsed') &&
+                    document.getElementById('navbar').getAttribute('aria-expanded') == 'false'
                 );
                 );
                 */
                 */
-                clean();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
                 if (!result) {
                 if (!result) {
                     console.log(results);
                     console.log(results);
@@ -364,36 +358,35 @@ describe('TopNav', function () {
             'reset inputs to defaults (options off)',
             'reset inputs to defaults (options off)',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="burnafterreading" name="burnafterreading" /> ' +
                     'id="burnafterreading" name="burnafterreading" /> ' +
                     'Burn after reading</label></li><li id="opendiscussionoption" ' +
                     'Burn after reading</label></li><li id="opendiscussionoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="opendiscussion" name="opendiscussion" /> ' +
                     'id="opendiscussion" name="opendiscussion" /> ' +
-                    'Open discussion</label></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Open discussion</label></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
-                $('#burnafterreading').attr('checked', 'checked');
-                $('#opendiscussion').attr('checked', 'checked');
+                query('#burnafterreading').checked = true;
+                query('#opendiscussion').checked = true;
                 results.push(
                 results.push(
-                    $.PrivateBin.TopNav.getBurnAfterReading()
+                    PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
                 results.push(
                 results.push(
-                    $.PrivateBin.TopNav.getOpenDiscussion()
+                    PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
-                $.PrivateBin.TopNav.resetInput();
+                PrivateBin.TopNav.resetInput();
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -408,35 +401,35 @@ describe('TopNav', function () {
             'reset inputs to defaults (burnafterreading on)',
             'reset inputs to defaults (burnafterreading on)',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="burnafterreading" name="burnafterreading" checked="checked" /> ' +
                     'id="burnafterreading" name="burnafterreading" checked="checked" /> ' +
                     'Burn after reading</label></li><li id="opendiscussionoption" ' +
                     'Burn after reading</label></li><li id="opendiscussionoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="opendiscussion" name="opendiscussion" checked="checked" /> ' +
                     'id="opendiscussion" name="opendiscussion" checked="checked" /> ' +
-                    'Open discussion</label></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Open discussion</label></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    $.PrivateBin.TopNav.getBurnAfterReading()
+                    PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
-                $('#burnafterreading').removeAttr('checked');
+                query('#burnafterreading').checked = false;
+                query('#burnafterreading').removeAttribute('checked');
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
-                $.PrivateBin.TopNav.resetInput();
+                PrivateBin.TopNav.resetInput();
                 results.push(
                 results.push(
-                    $.PrivateBin.TopNav.getBurnAfterReading()
+                    PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -451,36 +444,37 @@ describe('TopNav', function () {
             'reset inputs to defaults (opendiscussion on)',
             'reset inputs to defaults (opendiscussion on)',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="burnafterreading" name="burnafterreading" /> ' +
                     'id="burnafterreading" name="burnafterreading" /> ' +
                     'Burn after reading</label></li><li id="opendiscussionoption" ' +
                     'Burn after reading</label></li><li id="opendiscussionoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="opendiscussion" name="opendiscussion" checked="checked" /> ' +
                     'id="opendiscussion" name="opendiscussion" checked="checked" /> ' +
-                    'Open discussion</label></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Open discussion</label></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
                 results.push(
                 results.push(
-                    $.PrivateBin.TopNav.getOpenDiscussion()
+                    PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
-                $('#opendiscussion').removeAttr('checked');
-                $('#burnafterreading').prop('checked', true);
+                query('#opendiscussion').checked = false;
+                query('#opendiscussion').removeAttribute('checked');
+                query('#burnafterreading').checked = true;
+                query('#burnafterreading').setAttribute('checked', 'checked');
                 results.push(
                 results.push(
-                    $.PrivateBin.TopNav.getBurnAfterReading()
+                    PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
-                $.PrivateBin.TopNav.resetInput();
+                PrivateBin.TopNav.resetInput();
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
                 results.push(
                 results.push(
-                    $.PrivateBin.TopNav.getOpenDiscussion()
+                    PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -500,13 +494,12 @@ describe('TopNav', function () {
         it(
         it(
             'returns the currently selected expiration date',
             'returns the currently selected expiration date',
             function () {
             function () {
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<select id="pasteExpiration" name="pasteExpiration">' +
                     '<select id="pasteExpiration" name="pasteExpiration">' +
                     '<option value="1day">1 day</option>' +
                     '<option value="1day">1 day</option>' +
-                    '<option value="never">Never</option></select>'
-                );
-                $.PrivateBin.TopNav.init();
-                assert.strictEqual($.PrivateBin.TopNav.getExpiration(), '1day');
+                    '<option value="never">Never</option></select>';
+                PrivateBin.TopNav.init();
+                assert.strictEqual(PrivateBin.TopNav.getExpiration(), '1day');
                 cleanup();
                 cleanup();
             }
             }
         );
         );
@@ -558,7 +551,7 @@ describe('TopNav', function () {
             'returns the selected files',
             'returns the selected files',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
@@ -567,17 +560,16 @@ describe('TopNav', function () {
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
-                    '</li></ul></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '</li></ul></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    $.PrivateBin.TopNav.getFileList() === null
+                    PrivateBin.TopNav.getFileList() === null
                 );
                 );
-                addFileList($('#file')[0], [
+                addFileList(query('#file'), [
                     '../img/logo.svg',
                     '../img/logo.svg',
                     '../img/busy.gif'
                     '../img/busy.gif'
                 ]);
                 ]);
-                const files = $.PrivateBin.TopNav.getFileList();
+                const files = PrivateBin.TopNav.getFileList();
                 results.push(
                 results.push(
                     files[0].name === 'logo.svg' &&
                     files[0].name === 'logo.svg' &&
                     files[1].name === 'busy.gif'
                     files[1].name === 'busy.gif'
@@ -601,23 +593,23 @@ describe('TopNav', function () {
             'returns if the burn-after-reading checkbox was ticked',
             'returns if the burn-after-reading checkbox was ticked',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="burnafterreading" name="burnafterreading" /> ' +
                     'id="burnafterreading" name="burnafterreading" /> ' +
-                    'Burn after reading</label></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Burn after reading</label></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
-                $('#burnafterreading').attr('checked', 'checked');
+                query('#burnafterreading').checked = true;
                 results.push(
                 results.push(
-                    $.PrivateBin.TopNav.getBurnAfterReading()
+                    PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
-                $('#burnafterreading').removeAttr('checked');
+                query('#burnafterreading').checked = false;
+                query('#burnafterreading').removeAttribute('checked');
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -638,23 +630,23 @@ describe('TopNav', function () {
             'returns if the open-discussion checkbox was ticked',
             'returns if the open-discussion checkbox was ticked',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="opendiscussionoption" ' +
                     '<nav><div id="navbar"><ul><li id="opendiscussionoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="opendiscussion" name="opendiscussion" /> ' +
                     'id="opendiscussion" name="opendiscussion" /> ' +
-                    'Open discussion</label></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Open discussion</label></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
-                $('#opendiscussion').attr('checked', 'checked');
+                query('#opendiscussion').checked = true;
                 results.push(
                 results.push(
-                    $.PrivateBin.TopNav.getOpenDiscussion()
+                    PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
-                $('#opendiscussion').removeAttr('checked');
+                query('#opendiscussion').checked = false;
+                query('#opendiscussion').removeAttribute('checked');
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -668,41 +660,41 @@ describe('TopNav', function () {
 
 
     describe('getPassword', function () {
     describe('getPassword', function () {
         before(function () {
         before(function () {
-            cleanup();
+            globalThis.cleanup();
         });
         });
 
 
-        jsc.property(
-            'returns the contents of the password input',
-            'string',
-            function (password) {
-                password = password.replace(/\r+|\n+/g, '');
-                let results = [];
-                $('body').html(
-                    '<nav><div id="navbar"><ul><li><div id="password" ' +
-                    'class="navbar-form hidden"><input type="password" ' +
-                    'id="passwordinput" placeholder="Password (recommended)" ' +
-                    'class="form-control" size="23" /></div></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
-                results.push(
-                    $.PrivateBin.TopNav.getPassword() === ''
-                );
-                $('#passwordinput').val(password);
-                results.push(
-                    $.PrivateBin.TopNav.getPassword() === password
-                );
-                $('#passwordinput').val('');
-                results.push(
-                    $.PrivateBin.TopNav.getPassword() === ''
-                );
-                cleanup();
-                const result = results.every(element => element);
-                if (!result) {
-                    console.log(results);
+        it('returns the contents of the password input', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (password) {
+                    password = password.replace(/\r+|\n+/g, '');
+                    let results = [];
+                    document.documentElement.innerHTML =
+                        '<nav><div id="navbar"><ul><li><div id="password" ' +
+                        'class="navbar-form hidden"><input type="password" ' +
+                        'id="passwordinput" placeholder="Password (recommended)" ' +
+                        'class="form-control" size="23" /></div></li></ul></div></nav>';
+                    PrivateBin.TopNav.init();
+                    results.push(
+                        PrivateBin.TopNav.getPassword() === ''
+                    );
+                    query('#passwordinput').value = password;
+                    results.push(
+                        PrivateBin.TopNav.getPassword() === password
+                    );
+                    query('#passwordinput').value = '';
+                    results.push(
+                        PrivateBin.TopNav.getPassword() === ''
+                    );
+                    globalThis.cleanup();
+                    const result = results.every(element => element);
+                    if (!result) {
+                        console.log(results);
+                    }
+                    return result;
                 }
                 }
-                return result;
-            }
-        );
+            ));
+        });
     });
     });
 
 
     describe('getCustomAttachment', function () {
     describe('getCustomAttachment', function () {
@@ -714,7 +706,7 @@ describe('TopNav', function () {
             'returns the custom attachment element',
             'returns the custom attachment element',
             function () {
             function () {
                 let results = [];
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
@@ -723,15 +715,14 @@ describe('TopNav', function () {
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
-                    '</li></ul></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '</li></ul></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
                 results.push(
-                    !$.PrivateBin.TopNav.getCustomAttachment().hasClass('test')
+                    !PrivateBin.TopNav.getCustomAttachment().classList.contains('test')
                 );
                 );
-                $('#customattachment').addClass('test');
+                query('#customattachment').classList.add('test');
                 results.push(
                 results.push(
-                    $.PrivateBin.TopNav.getCustomAttachment().hasClass('test')
+                    PrivateBin.TopNav.getCustomAttachment().classList.contains('test')
                 );
                 );
                 cleanup();
                 cleanup();
                 const result = results.every(element => element);
                 const result = results.every(element => element);
@@ -753,7 +744,7 @@ describe('TopNav', function () {
             function () {
             function () {
                 // Insert any setup code needed for the hideAllButtons function
                 // Insert any setup code needed for the hideAllButtons function
                 // Example: Initialize the DOM elements required for testing
                 // Example: Initialize the DOM elements required for testing
-                $('body').html(
+                document.body.innerHTML =
                     '<nav class="navbar navbar-inverse navbar-static-top">' +
                     '<nav class="navbar navbar-inverse navbar-static-top">' +
                     '<div id="navbar" class="navbar-collapse collapse"><ul ' +
                     '<div id="navbar" class="navbar-collapse collapse"><ul ' +
                     'class="nav navbar-nav"><li><button id="newbutton" ' +
                     'class="nav navbar-nav"><li><button id="newbutton" ' +
@@ -770,15 +761,14 @@ describe('TopNav', function () {
                     'data-toggle="modal" data-target="#qrcodemodal" ' +
                     'data-toggle="modal" data-target="#qrcodemodal" ' +
                     'class="hidden btn btn-warning navbar-btn"><span ' +
                     'class="hidden btn btn-warning navbar-btn"><span ' +
                     'class="glyphicon glyphicon-qrcode" aria-hidden="true">' +
                     'class="glyphicon glyphicon-qrcode" aria-hidden="true">' +
-                    '</span> QR code</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
-                $.PrivateBin.TopNav.hideAllButtons();
-
-                assert.ok($('#newbutton').hasClass('hidden'));
-                assert.ok($('#clonebutton').hasClass('hidden'));
-                assert.ok($('#rawtextbutton').hasClass('hidden'));
-                assert.ok($('#qrcodelink').hasClass('hidden'));
+                    '</span> QR code</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
+                PrivateBin.TopNav.hideAllButtons();
+
+                assert.ok(query('#newbutton').classList.contains('hidden'));
+                assert.ok(query('#clonebutton').classList.contains('hidden'));
+                assert.ok(query('#rawtextbutton').classList.contains('hidden'));
+                assert.ok(query('#qrcodelink').classList.contains('hidden'));
                 cleanup();
                 cleanup();
             }
             }
         );
         );
@@ -793,14 +783,17 @@ describe('TopNav', function () {
         it(
         it(
             'displays raw text view correctly',
             'displays raw text view correctly',
             function () {
             function () {
-                const clean = jsdom('', {url: 'https://privatebin.net/?0123456789abcdef#1'});
-                $('body').html('<button id="rawtextbutton"></button>');
+                const clean = globalThis.cleanup('', {url: 'https://privatebin.net/?0123456789abcdef#1'});
+                document.documentElement.innerHTML = `
+                <li id="loadingindicator" class="hidden"></li>
+                <button id="rawtextbutton"></button>`;
                 const sample = 'example';
                 const sample = 'example';
-                $.PrivateBin.PasteViewer.setText(sample);
-                $.PrivateBin.Helper.reset();
-                $.PrivateBin.TopNav.init();
-                $('#rawtextbutton').click();
-                assert.strictEqual($('pre').text(), sample);
+                PrivateBin.Alert.init(); // required because of locading indiator being used
+                PrivateBin.TopNav.init();
+                PrivateBin.PasteViewer.setText(sample);
+                PrivateBin.Helper.reset();
+                query('#rawtextbutton').click();
+                assert.strictEqual(document.querySelector('pre').textContent, sample);
                 clean();
                 clean();
             }
             }
         );
         );

+ 51 - 77
js/test/UiHelper.js

@@ -1,5 +1,6 @@
 'use strict';
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 
 describe('UiHelper', function () {
 describe('UiHelper', function () {
     // TODO: As per https://github.com/tmpvar/jsdom/issues/1565 there is no navigation support in jsdom, yet.
     // TODO: As per https://github.com/tmpvar/jsdom/issues/1565 there is no navigation support in jsdom, yet.
@@ -7,43 +8,45 @@ describe('UiHelper', function () {
     describe('historyChange', function () {
     describe('historyChange', function () {
         this.timeout(30000);
         this.timeout(30000);
         beforeEach(function () {
         beforeEach(function () {
-            $.PrivateBin.Helper.reset();
+            PrivateBin.Helper.reset();
             cleanup();
             cleanup();
         });
         });
 
 
-        jsc.property(
-            'redirects to home, when the state is null',
-            common.jscUrl(false, false),
-            function (url) {
-                const expected = common.urlToString(url),
-                    clean = jsdom('', {url: expected});
+        it('redirects to home, when the state is null', () => {
+            fc.assert(fc.property(
+                common.fcUrl(false, false),
+                function (url) {
+                    const expected = common.urlToString(url),
+                        clean = globalThis.cleanup('', {url: expected});
 
 
-                $.PrivateBin.UiHelper.mockHistoryChange();
-                $.PrivateBin.Helper.reset();
-                var result = window.location.href;
-                clean();
-                return expected === result;
-            }
-        );
+                    PrivateBin.UiHelper.mockHistoryChange();
+                    PrivateBin.Helper.reset();
+                    var result = window.location.href;
+                    clean();
+                    return expected === result;
+                }
+            ));
+        });
 
 
-        jsc.property(
-            'does not redirect to home, when a new document is created',
-            common.jscUrl(false),
-            jsc.nearray(common.jscBase64String()),
-            function (url, fragment) {
-                url.fragment = fragment.join('');
-                const expected = common.urlToString(url),
-                    clean = jsdom('', {url: expected});
+        it('does not redirect to home, when a new document is created', () => {
+            fc.assert(fc.property(
+                common.fcUrl(false),
+                fc.array(common.fcBase64String(), {minLength: 1}),
+                function (url, fragment) {
+                    url.fragment = fragment.join('');
+                    const expected = common.urlToString(url),
+                        clean = globalThis.cleanup('', {url: expected});
 
 
-                $.PrivateBin.UiHelper.mockHistoryChange([
-                    {type: 'newpaste'}, '', expected
-                ]);
-                $.PrivateBin.Helper.reset();
-                var result = window.location.href;
-                clean();
-                return expected === result;
-            }
-        );
+                    PrivateBin.UiHelper.mockHistoryChange([
+                        {type: 'newpaste'}, '', expected
+                    ]);
+                    PrivateBin.Helper.reset();
+                    var result = window.location.href;
+                    clean();
+                    return expected === result;
+                }
+            ));
+        });
     });
     });
 
 
     describe('reloadHome', function () {
     describe('reloadHome', function () {
@@ -51,56 +54,27 @@ describe('UiHelper', function () {
         /*
         /*
         this.timeout(30000);
         this.timeout(30000);
         before(function () {
         before(function () {
-            $.PrivateBin.Helper.reset();
+            PrivateBin.Helper.reset();
         });
         });
 
 
-        jsc.property(
-            'redirects to home',
-            common.jscUrl(),
-            function (url) {
-                const clean = jsdom('', {url: common.urlToString(url)});
-                delete(url.query);
-                delete(url.fragment);
-                const expected = common.urlToString(url);
-
-                $.PrivateBin.UiHelper.reloadHome();
-                $.PrivateBin.Helper.reset();
-                var result = window.location.href;
-                clean();
-                return expected === result;
-            }
-        );
-        */
-    });
+        it('redirects to home', () => {
+            fc.assert(fc.property(
+                common.fcUrl(),
+                function (url) {
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)});
+                    delete(url.query);
+                    delete(url.fragment);
+                    const expected = common.urlToString(url);
 
 
-    describe('isVisible', function () {
-        // TODO As per https://github.com/tmpvar/jsdom/issues/1048 there is no layout support in jsdom, yet.
-        // once it is supported or a workaround is found, uncomment the section below
-        /*
-        before(function () {
-            $.PrivateBin.Helper.reset();
+                    PrivateBin.UiHelper.reloadHome();
+                    PrivateBin.Helper.reset();
+                    var result = window.location.href;
+                    clean();
+                    return expected === result;
+                }
+            ));
         });
         });
-
-        jsc.property(
-            'detect visible elements',
-            jsc.nearray(common.jscAlnumString()),
-            jsc.nearray(common.jscA2zString()),
-            function (id, element) {
-                id = id.join('');
-                element = element.join('');
-                var clean = jsdom(
-                    '<' + element + ' id="' + id + '"></' + element + '>'
-                );
-                var result = $.PrivateBin.UiHelper.isVisible($('#' + id));
-                clean();
-                return result;
-            }
-        );
         */
         */
     });
     });
-
-    describe('scrollTo', function () {
-        // TODO Did not find a way to test that, see isVisible test above
-    });
 });
 });
 
 

+ 165 - 47
js/test/emailTemplateTest.js

@@ -3,7 +3,7 @@ require('../common');
 
 
 // DOM builder that mirrors bootstrap5.php navbar
 // DOM builder that mirrors bootstrap5.php navbar
 function buildEmailDomNoShortUrl() {
 function buildEmailDomNoShortUrl() {
-    $('body').html(
+    document.documentElement.innerHTML =
         // TopNav expects initially hidden #emaillink BUTTON.
         // TopNav expects initially hidden #emaillink BUTTON.
         '<nav><div id="navbar"><ul>' +
         '<nav><div id="navbar"><ul>' +
           '<li>' +
           '<li>' +
@@ -15,14 +15,18 @@ function buildEmailDomNoShortUrl() {
             '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
             '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
           '</li>' +
           '</li>' +
         '</ul></div></nav>' +
         '</ul></div></nav>' +
-        '<input id="burnafterreadingoption" type="checkbox">'
-    );
+        '<input id="burnafterreadingoption" type="checkbox">' +
+        // include dummy email confirm modal for sendEmail
+        '<div id="emailconfirmmodal" class="hidden">' +
+            '<div id="emailconfirm-timezone-current"></div>' +
+            '<div id="emailconfirm-timezone-utc"></div>' +
+        '</div>'
 }
 }
 
 
 // DOM builder that adds the shortener result block
 // DOM builder that adds the shortener result block
 function buildEmailDomWithShortUrl() {
 function buildEmailDomWithShortUrl() {
     buildEmailDomNoShortUrl();
     buildEmailDomNoShortUrl();
-    $('body').html(
+    document.documentElement.innerHTML =
         // TopNav expectsinitially hidden #emaillink BUTTON.
         // TopNav expectsinitially hidden #emaillink BUTTON.
         '<nav><div id="navbar"><ul>' +
         '<nav><div id="navbar"><ul>' +
           '<li>' +
           '<li>' +
@@ -38,67 +42,79 @@ function buildEmailDomWithShortUrl() {
         '<div id="pastelink">Your document is ' +
         '<div id="pastelink">Your document is ' +
           '<a id="pasteurl" href="https://short.example/xYz">https://short.example/xYz</a> ' +
           '<a id="pasteurl" href="https://short.example/xYz">https://short.example/xYz</a> ' +
           '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>' +
           '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>' +
+        '</div>' +
+        // add a minimal email confirmation modal so sendEmail does not crash
+        '<div id="emailconfirmmodal" class="hidden">' +
+            '<div id="emailconfirm-timezone-current"></div>' +
+            '<div id="emailconfirm-timezone-utc"></div>' +
         '</div>'
         '</div>'
-    );
 }
 }
 
 
 
 
-function stubWinOpen($element) {
-    const win = $element[0].ownerDocument.defaultView;
-
-    // Some helpers in privatebin.js expect a global document.
-    global.document = win.document;
-
+function makeWindowOpenMock() {
+    const originalOpen = window.open;
     let openedUrl = null;
     let openedUrl = null;
-    const origOpen = win.open;
+    let mockRestoreFn = null;
 
 
-    // Prefer simple assignment; if blocked, fall back to defineProperty.
-    try {
-        win.open = function (url) {
+    if (typeof jest !== 'undefined' && typeof jest.spyOn === 'function') {
+        const spy = jest.spyOn(window, 'open').mockImplementation((url) => {
             openedUrl = url;
             openedUrl = url;
             return {};
             return {};
-        };
-    } catch (e) {
-        Object.defineProperty(win, 'open', {
-            value: function (url) {
-                openedUrl = url;
-                return {};
-            },
-            configurable: true,
-            writable: true
         });
         });
+        mockRestoreFn = () => spy.mockRestore();
+    } else {
+        window.open = function (url) {
+            openedUrl = url;
+            return {};
+        };
+        mockRestoreFn = () => { window.open = originalOpen; };
     }
     }
 
 
     return {
     return {
         getUrl: () => openedUrl,
         getUrl: () => openedUrl,
-        restore: () => { try { win.open = origOpen; } catch (e) { /* suppress exception in restore */ } },
-        win
+        restore: () => {
+            if (mockRestoreFn) {
+                mockRestoreFn();
+            }
+        }
     };
     };
 }
 }
 
 
 
 
-// Extract and decode the body from a "mailto:?body=..." URL.
+// Extract and decode the body from a "mailto:?subject=...&body=..." URL.
 function extractMailtoBody(mailtoUrl) {
 function extractMailtoBody(mailtoUrl) {
-    assert.match(mailtoUrl, /^mailto:\?body=/, 'expected a mailto:?body= URL');
-    return decodeURIComponent(mailtoUrl.replace(/^mailto:\?body=/, ''));
+    assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
+    const match = mailtoUrl.match(/[?&]body=([^&]*)/);
+    assert.ok(match, 'expected the mailto: URL to have a body= parameter');
+    return decodeURIComponent(match[1]);
+}
+
+// Extract and decode the subject from a "mailto:?subject=...&body=..." URL.
+function extractMailtoSubject(mailtoUrl) {
+    assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
+    const match = mailtoUrl.match(/[?&]subject=([^&]*)/);
+    assert.ok(match, 'expected the mailto: URL to have a subject= parameter');
+    return decodeURIComponent(match[1]);
 }
 }
 
 
 describe('Email - mail body content (short URL vs. fallback)', function () {
 describe('Email - mail body content (short URL vs. fallback)', function () {
-    before(function () {
+    beforeEach(function () {
         cleanup(); // provided by common
         cleanup(); // provided by common
     });
     });
 
 
     it('Uses the short URL when #pasteurl is present and never includes "undefined"', function () {
     it('Uses the short URL when #pasteurl is present and never includes "undefined"', function () {
-        buildEmailDomWithShortUrl();               // with #pastelink/#pasteurl
-        $.PrivateBin.TopNav.init();
-        $.PrivateBin.TopNav.showEmailButton(0);
+        buildEmailDomWithShortUrl(); // with #pastelink/#pasteurl
+        PrivateBin.TopNav.init();
+        PrivateBin.TopNav.showEmailButton(0);
 
 
-        const $emailBtn = $('#emaillink');
-        assert.ok(!$emailBtn.hasClass('hidden'), '#emaillink should be visible after showEmailButton');
+        const emailBtn = document.getElementById('emaillink');
+        assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
 
 
-        const { getUrl, restore } = stubWinOpen($emailBtn);
+        const { getUrl, restore } = makeWindowOpenMock();
         try {
         try {
-            $emailBtn.trigger('click');
+            emailBtn.click();
+            document.getElementById('emailconfirm-timezone-current').click();
+
             const openedUrl = getUrl();
             const openedUrl = getUrl();
             assert.ok(openedUrl, 'window.open should have been called');
             assert.ok(openedUrl, 'window.open should have been called');
 
 
@@ -107,30 +123,132 @@ describe('Email - mail body content (short URL vs. fallback)', function () {
             assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
             assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
         } finally {
         } finally {
             restore();
             restore();
-            cleanup();
         }
         }
     });
     });
 
 
     it('Falls back to window.location.href when #pasteurl is absent and never includes "undefined"', function () {
     it('Falls back to window.location.href when #pasteurl is absent and never includes "undefined"', function () {
-        buildEmailDomNoShortUrl();      // No #pasteurl
-        $.PrivateBin.TopNav.init();
-        $.PrivateBin.TopNav.showEmailButton(0);
+        buildEmailDomNoShortUrl(); // No #pasteurl
+        PrivateBin.TopNav.init();
+        PrivateBin.TopNav.showEmailButton(0);
 
 
-        const $emailBtn = $('#emaillink');
-        assert.ok(!$emailBtn.hasClass('hidden'), '#emaillink should be visible after showEmailButton');
+        const emailBtn = document.getElementById('emaillink');
+        assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
 
 
-        const { getUrl, restore, win } = stubWinOpen($emailBtn);
+        const { getUrl, restore } = makeWindowOpenMock();
         try {
         try {
-            $emailBtn.trigger('click');
+            emailBtn.click();
+            document.getElementById('emailconfirm-timezone-current').click();
+
             const openedUrl = getUrl();
             const openedUrl = getUrl();
             assert.ok(openedUrl, 'window.open should have been called');
             assert.ok(openedUrl, 'window.open should have been called');
 
 
             const body = extractMailtoBody(openedUrl);
             const body = extractMailtoBody(openedUrl);
-            assert.match(body, new RegExp(win.location.href), 'email body should include the fallback page URL');
+            assert.match(body, new RegExp(window.location.href), 'email body should include the fallback page URL');
             assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
             assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
         } finally {
         } finally {
             restore();
             restore();
-            cleanup();
+        }
+    });
+
+    it('Uses the viewed paste burn-after-reading state instead of the instance default', function () {
+        buildEmailDomNoShortUrl();
+        const burnAfterReading = document.createElement('input');
+        burnAfterReading.id = 'burnafterreading';
+        burnAfterReading.type = 'checkbox';
+        burnAfterReading.checked = true;
+        document.body.appendChild(burnAfterReading);
+        PrivateBin.TopNav.init();
+        PrivateBin.TopNav.showEmailButton(0, false);
+
+        const { getUrl, restore } = makeWindowOpenMock();
+        try {
+            document.getElementById('emaillink').click();
+            document.getElementById('emailconfirm-timezone-current').click();
+
+            const body = extractMailtoBody(getUrl());
+            assert.doesNotMatch(
+                body,
+                /only be accessed once/,
+                'email body must use the viewed paste metadata'
+            );
+        } finally {
+            restore();
+        }
+    });
+});
+
+describe('Email - mail subject', function () {
+    beforeEach(function () {
+        cleanup(); // provided by common
+    });
+
+    it('Includes a non-empty subject naming the instance, with no expiration confirmation step', function () {
+        buildEmailDomNoShortUrl();
+        // buildEmailDomNoShortUrl() replaces documentElement.innerHTML (dropping <title>),
+        // so document.title must be (re-)set after calling it, not before.
+        document.title = 'My PrivateBin Instance';
+        PrivateBin.TopNav.init();
+        PrivateBin.TopNav.showEmailButton(0);
+
+        const { getUrl, restore } = makeWindowOpenMock();
+        try {
+            document.getElementById('emaillink').click();
+
+            const openedUrl = getUrl();
+            assert.ok(openedUrl, 'window.open should have been called');
+
+            const subject = extractMailtoSubject(openedUrl);
+            assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
+        } finally {
+            restore();
+        }
+    });
+
+    it('Includes the same subject after the expiration confirmation step', function () {
+        buildEmailDomWithShortUrl();
+        document.title = 'My PrivateBin Instance';
+        PrivateBin.TopNav.init();
+        // a non-zero remaining time routes through the timezone confirmation modal
+        PrivateBin.TopNav.showEmailButton(3600);
+
+        const { getUrl, restore } = makeWindowOpenMock();
+        try {
+            document.getElementById('emaillink').click();
+            document.getElementById('emailconfirm-timezone-current').click();
+
+            const openedUrl = getUrl();
+            assert.ok(openedUrl, 'window.open should have been called');
+
+            const subject = extractMailtoSubject(openedUrl);
+            assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
+        } finally {
+            restore();
+        }
+    });
+
+    it('Percent-encodes the subject so a space-containing instance name does not break the mailto URL', function () {
+        buildEmailDomNoShortUrl();
+        document.title = 'My Cool Instance';
+        PrivateBin.TopNav.init();
+        PrivateBin.TopNav.showEmailButton(0);
+
+        const { getUrl, restore } = makeWindowOpenMock();
+        try {
+            document.getElementById('emaillink').click();
+
+            const openedUrl = getUrl();
+            const rawSubjectParam = openedUrl.split('&body=')[0];
+            assert.strictEqual(
+                rawSubjectParam,
+                `mailto:?subject=${encodeURIComponent('Encrypted note on My Cool Instance')}`,
+                'raw subject parameter should be exactly the encoded translated string'
+            );
+            assert.doesNotMatch(rawSubjectParam, / /, 'raw mailto URL must not contain a literal space');
+
+            const subject = extractMailtoSubject(openedUrl);
+            assert.strictEqual(subject, 'Encrypted note on My Cool Instance', 'decoded subject should round-trip exactly');
+        } finally {
+            restore();
         }
         }
     });
     });
 });
 });

+ 47 - 45
lib/Configuration.php

@@ -33,8 +33,8 @@ class Configuration
      *
      *
      * @var array
      * @var array
      */
      */
-    private static $_defaults = array(
-        'main' => array(
+    private static $_defaults = [
+        'main' => [
             'name'                     => 'PrivateBin',
             'name'                     => 'PrivateBin',
             'basepath'                 => '',
             'basepath'                 => '',
             'discussion'               => true,
             'discussion'               => true,
@@ -48,7 +48,7 @@ class Configuration
             'sizelimit'                => 10485760,
             'sizelimit'                => 10485760,
             'templateselection'        => false,
             'templateselection'        => false,
             'template'                 => 'bootstrap5',
             'template'                 => 'bootstrap5',
-            'availabletemplates'       => array(
+            'availabletemplates'       => [
                 'bootstrap5',
                 'bootstrap5',
                 'bootstrap',
                 'bootstrap',
                 'bootstrap-page',
                 'bootstrap-page',
@@ -56,7 +56,7 @@ class Configuration
                 'bootstrap-dark-page',
                 'bootstrap-dark-page',
                 'bootstrap-compact',
                 'bootstrap-compact',
                 'bootstrap-compact-page',
                 'bootstrap-compact-page',
-            ),
+            ],
             'info'                     => 'More information on the <a href=\'https://privatebin.info/\'>project page</a>.',
             'info'                     => 'More information on the <a href=\'https://privatebin.info/\'>project page</a>.',
             'notice'                   => '',
             'notice'                   => '',
             'languageselection'        => false,
             'languageselection'        => false,
@@ -69,11 +69,11 @@ class Configuration
             'cspheader'                => 'default-src \'none\'; base-uri \'self\'; form-action \'none\'; manifest-src \'self\'; connect-src * blob:; script-src \'self\' \'wasm-unsafe-eval\'; style-src \'self\'; font-src \'self\'; frame-ancestors \'none\'; frame-src blob:; img-src \'self\' data: blob:; media-src blob:; object-src blob:; sandbox allow-same-origin allow-scripts allow-forms allow-modals allow-downloads',
             'cspheader'                => 'default-src \'none\'; base-uri \'self\'; form-action \'none\'; manifest-src \'self\'; connect-src * blob:; script-src \'self\' \'wasm-unsafe-eval\'; style-src \'self\'; font-src \'self\'; frame-ancestors \'none\'; frame-src blob:; img-src \'self\' data: blob:; media-src blob:; object-src blob:; sandbox allow-same-origin allow-scripts allow-forms allow-modals allow-downloads',
             'httpwarning'              => true,
             'httpwarning'              => true,
             'compression'              => 'zlib',
             'compression'              => 'zlib',
-        ),
-        'expire' => array(
+        ],
+        'expire' => [
             'default' => '1week',
             'default' => '1week',
-        ),
-        'expire_options' => array(
+        ],
+        'expire_options' => [
             '5min'   => 300,
             '5min'   => 300,
             '10min'  => 600,
             '10min'  => 600,
             '1hour'  => 3600,
             '1hour'  => 3600,
@@ -82,53 +82,55 @@ class Configuration
             '1month' => 2592000,
             '1month' => 2592000,
             '1year'  => 31536000,
             '1year'  => 31536000,
             'never'  => 0,
             'never'  => 0,
-        ),
-        'formatter_options' => array(
+        ],
+        'formatter_options' => [
             'plaintext'          => 'Plain Text',
             'plaintext'          => 'Plain Text',
             'syntaxhighlighting' => 'Source Code',
             'syntaxhighlighting' => 'Source Code',
             'markdown'           => 'Markdown',
             'markdown'           => 'Markdown',
-        ),
-        'traffic' => array(
+        ],
+        'traffic' => [
             'limit'     => 10,
             'limit'     => 10,
             'header'    => '',
             'header'    => '',
             'exempted'  => '',
             'exempted'  => '',
             'creators'  => '',
             'creators'  => '',
-        ),
-        'purge' => array(
+        ],
+        'purge' => [
             'limit'     => 300,
             'limit'     => 300,
             'batchsize' => 10,
             'batchsize' => 10,
-        ),
-        'model' => array(
+        ],
+        'model' => [
             'class' => 'Filesystem',
             'class' => 'Filesystem',
-        ),
-        'model_options' => array(
+        ],
+        'model_options' => [
             'dir' => 'data',
             'dir' => 'data',
-        ),
-        'yourls' => array(
+        ],
+        'yourls' => [
             'signature' => '',
             'signature' => '',
             'apiurl'    => '',
             'apiurl'    => '',
-        ),
-        'shlink' => array(
+        ],
+        'shlink' => [
             'apikey'    => '',
             'apikey'    => '',
             'apiurl'    => '',
             'apiurl'    => '',
-        ),
+        ],
+        'chhoto' => [
+            'apikey'    => '',
+            'apiurl'    => '',
+        ],
         // update this array when adding/changing/removing js files
         // update this array when adding/changing/removing js files
-        'sri' => array(
+        'sri' => [
             'js/base-x-5.0.1.js'     => 'sha512-FmhlnjIxQyxkkxQmzf0l6IRGsGbgyCdgqPxypFsEtHMF1naRqaLLo6mcyN5rEaT16nKx1PeJ4g7+07D6gnk/Tg==',
             'js/base-x-5.0.1.js'     => 'sha512-FmhlnjIxQyxkkxQmzf0l6IRGsGbgyCdgqPxypFsEtHMF1naRqaLLo6mcyN5rEaT16nKx1PeJ4g7+07D6gnk/Tg==',
-            'js/bootstrap-3.4.1.js'  => 'sha512-oBTprMeNEKCnqfuqKd6sbvFzmFQtlXS3e0C/RGFV0hD6QzhHV+ODfaQbAlmY6/q0ubbwlAM/nCJjkrgA3waLzg==',
             'js/bootstrap-5.3.8.js'  => 'sha512-BkZvJ5rZ3zbDCod5seWHpRGg+PRd6ZgE8Nua/OMtcxqm8Wtg0PqwhUUXK5bqvl3oclMt5O+3zjRVX0L+L2j7fA==',
             'js/bootstrap-5.3.8.js'  => 'sha512-BkZvJ5rZ3zbDCod5seWHpRGg+PRd6ZgE8Nua/OMtcxqm8Wtg0PqwhUUXK5bqvl3oclMt5O+3zjRVX0L+L2j7fA==',
             'js/dark-mode-switch.js' => 'sha512-BhY7dNU14aDN5L+muoUmA66x0CkYUWkQT0nxhKBLP/o2d7jE025+dvWJa4OiYffBGEFgmhrD/Sp+QMkxGMTz2g==',
             'js/dark-mode-switch.js' => 'sha512-BhY7dNU14aDN5L+muoUmA66x0CkYUWkQT0nxhKBLP/o2d7jE025+dvWJa4OiYffBGEFgmhrD/Sp+QMkxGMTz2g==',
-            'js/jquery-3.7.1.js'     => 'sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==',
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
-            'js/legacy.js'           => 'sha512-RQEo1hxpNc37i+jz/D9/JiAZhG8GFx3+SNxjYnI7jUgirDIqrCSj6QPAAZeaidditcWzsJ3jxfEj5lVm7ZwTRQ==',
+            'js/legacy.js'           => 'sha512-pRofxsrf5UItjiP22Dcjh3FAcBjF/n7h8U9/W5xqJk17U0N2U1oajhXypq/omo9jhwS1iVGOhWrRepoPeFns+w==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
-            'js/privatebin.js'       => 'sha512-U52jFm7xsPJsJ7M/sFF6GoEBLTQ4pcvNDN5z3JcH0P2xHokiV3YrhfSi6XgTnBANdnjAXmg9GfJf3Bs2yKAiug==',
-            'js/purify-3.4.1.js'     => 'sha512-280a/Vb6fVFsYaeRrkuDp4EDmdYlt2XS+dlDEO/U9qljPrAraA2bIzHTNmP+9dpwPDDwTML+RS+h5iaagPwTzA==',
+            'js/privatebin.js'       => 'sha512-uqpdo+52wd4/uzOgfilGFw1SX3faeOuHWSRvfRwTUj8q7WPUoIArxVmp1DOeZzZUhEI/4NDJkNsQvvrKp8uQ/w==',
+            'js/purify-3.4.12.js'    => 'sha512-Akf6HnAJZm0sWWWI4gp2GYff0NDnHUB02XJE5S7Hdq/Z5xtMjkuFacsDA8ZtViv1gi+onBxMhEMIaGyQeGxBng==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',
             'js/zlib.js'             => 'sha512-QOaEwssHqHRRcWJ2Un3Kl2Zhyprzl7T8zmsKN2FppFxW3VR+8UChYOx2iuL0HbXK42fuBWJm5PNQJxufulrt/w==',
             'js/zlib.js'             => 'sha512-QOaEwssHqHRRcWJ2Un3Kl2Zhyprzl7T8zmsKN2FppFxW3VR+8UChYOx2iuL0HbXK42fuBWJm5PNQJxufulrt/w==',
-        ),
-    );
+        ],
+    ];
 
 
     /**
     /**
      * parse configuration file and ensure default configuration values are present
      * parse configuration file and ensure default configuration values are present
@@ -137,8 +139,8 @@ class Configuration
      */
      */
     public function __construct()
     public function __construct()
     {
     {
-        $basePaths  = array();
-        $config     = array();
+        $basePaths  = [];
+        $config     = [];
         $configPath = getenv('CONFIG_PATH');
         $configPath = getenv('CONFIG_PATH');
         if ($configPath !== false && !empty($configPath)) {
         if ($configPath !== false && !empty($configPath)) {
             $basePaths[] = $configPath;
             $basePaths[] = $configPath;
@@ -148,10 +150,10 @@ class Configuration
             $configFile = $basePath . DIRECTORY_SEPARATOR . 'conf.php';
             $configFile = $basePath . DIRECTORY_SEPARATOR . 'conf.php';
             if (is_readable($configFile)) {
             if (is_readable($configFile)) {
                 $config = parse_ini_file($configFile, true);
                 $config = parse_ini_file($configFile, true);
-                foreach (array('main', 'model', 'model_options') as $section) {
+                foreach (['main', 'model', 'model_options'] as $section) {
                     if (!array_key_exists($section, $config)) {
                     if (!array_key_exists($section, $config)) {
                         $name = $config['main']['name'] ?? self::getDefaults()['main']['name'];
                         $name = $config['main']['name'] ?? self::getDefaults()['main']['name'];
-                        throw new TranslatedException(array('%s requires configuration section [%s] to be present in configuration file.', I18n::_($name), $section), 2);
+                        throw new TranslatedException(['%s requires configuration section [%s] to be present in configuration file.', I18n::_($name), $section], 2);
                     }
                     }
                 }
                 }
                 break;
                 break;
@@ -173,27 +175,27 @@ class Configuration
                 $section === 'model_options' &&
                 $section === 'model_options' &&
                 $this->_configuration['model']['class'] === 'Database'
                 $this->_configuration['model']['class'] === 'Database'
             ) {
             ) {
-                $values = array(
+                $values = [
                     'dsn' => 'sqlite:' . PATH . 'data' . DIRECTORY_SEPARATOR . 'db.sq3',
                     'dsn' => 'sqlite:' . PATH . 'data' . DIRECTORY_SEPARATOR . 'db.sq3',
                     'tbl' => null,
                     'tbl' => null,
                     'usr' => null,
                     'usr' => null,
                     'pwd' => null,
                     'pwd' => null,
-                    'opt' => array(),
-                );
+                    'opt' => [],
+                ];
             } elseif (
             } elseif (
                 $section === 'model_options' &&
                 $section === 'model_options' &&
                 $this->_configuration['model']['class'] === 'GoogleCloudStorage'
                 $this->_configuration['model']['class'] === 'GoogleCloudStorage'
             ) {
             ) {
-                $values = array(
+                $values = [
                     'bucket'     => getenv('PRIVATEBIN_GCS_BUCKET') ? getenv('PRIVATEBIN_GCS_BUCKET') : null,
                     'bucket'     => getenv('PRIVATEBIN_GCS_BUCKET') ? getenv('PRIVATEBIN_GCS_BUCKET') : null,
                     'prefix'     => 'pastes',
                     'prefix'     => 'pastes',
                     'uniformacl' => false,
                     'uniformacl' => false,
-                );
+                ];
             } elseif (
             } elseif (
                 $section === 'model_options' &&
                 $section === 'model_options' &&
                 $this->_configuration['model']['class'] === 'S3Storage'
                 $this->_configuration['model']['class'] === 'S3Storage'
             ) {
             ) {
-                $values = array(
+                $values = [
                     'region'                  => null,
                     'region'                  => null,
                     'version'                 => null,
                     'version'                 => null,
                     'endpoint'                => null,
                     'endpoint'                => null,
@@ -202,7 +204,7 @@ class Configuration
                     'use_path_style_endpoint' => null,
                     'use_path_style_endpoint' => null,
                     'bucket'                  => null,
                     'bucket'                  => null,
                     'prefix'                  => '',
                     'prefix'                  => '',
-                );
+                ];
             }
             }
 
 
             // "*_options" sections don't require all defaults to be set
             // "*_options" sections don't require all defaults to be set
@@ -232,9 +234,9 @@ class Configuration
                             $result = $config[$section][$key];
                             $result = $config[$section][$key];
                         } elseif (is_bool($val)) {
                         } elseif (is_bool($val)) {
                             $val = strtolower($config[$section][$key]);
                             $val = strtolower($config[$section][$key]);
-                            if (in_array($val, array('true', 'yes', 'on'))) {
+                            if (in_array($val, ['true', 'yes', 'on'])) {
                                 $result = true;
                                 $result = true;
-                            } elseif (in_array($val, array('false', 'no', 'off'))) {
+                            } elseif (in_array($val, ['false', 'no', 'off'])) {
                                 $result = false;
                                 $result = false;
                             } else {
                             } else {
                                 $result = (bool) $config[$section][$key];
                                 $result = (bool) $config[$section][$key];
@@ -313,7 +315,7 @@ class Configuration
     public function getSection($section)
     public function getSection($section)
     {
     {
         if (!array_key_exists($section, $this->_configuration)) {
         if (!array_key_exists($section, $this->_configuration)) {
-            throw new TranslatedException(array('%s requires configuration section [%s] to be present in configuration file.', I18n::_($this->getKey('name')), $section), 3);
+            throw new TranslatedException(['%s requires configuration section [%s] to be present in configuration file.', I18n::_($this->getKey('name')), $section], 3);
         }
         }
         return $this->_configuration[$section];
         return $this->_configuration[$section];
     }
     }

+ 29 - 21
lib/Controller.php

@@ -12,11 +12,11 @@
 namespace PrivateBin;
 namespace PrivateBin;
 
 
 use Exception;
 use Exception;
-use PrivateBin\Exception\JsonException;
 use PrivateBin\Exception\TranslatedException;
 use PrivateBin\Exception\TranslatedException;
 use PrivateBin\Persistence\ServerSalt;
 use PrivateBin\Persistence\ServerSalt;
 use PrivateBin\Persistence\TrafficLimiter;
 use PrivateBin\Persistence\TrafficLimiter;
 use PrivateBin\Proxy\AbstractProxy;
 use PrivateBin\Proxy\AbstractProxy;
+use PrivateBin\Proxy\ChhotoProxy;
 use PrivateBin\Proxy\ShlinkProxy;
 use PrivateBin\Proxy\ShlinkProxy;
 use PrivateBin\Proxy\YourlsProxy;
 use PrivateBin\Proxy\YourlsProxy;
 
 
@@ -32,7 +32,7 @@ class Controller
      *
      *
      * @const string
      * @const string
      */
      */
-    const VERSION = '2.0.4';
+    const VERSION = '2.0.6';
 
 
     /**
     /**
      * minimal required PHP version
      * minimal required PHP version
@@ -128,7 +128,7 @@ class Controller
             error_log(I18n::_('%s requires php %s or above to work. Sorry.', I18n::_('PrivateBin'), self::MIN_PHP_VERSION));
             error_log(I18n::_('%s requires php %s or above to work. Sorry.', I18n::_('PrivateBin'), self::MIN_PHP_VERSION));
             return;
             return;
         }
         }
-        if (strlen(PATH) < 0 && substr(PATH, -1) !== DIRECTORY_SEPARATOR) {
+        if (strlen(PATH) > 0 && substr(PATH, -1) !== DIRECTORY_SEPARATOR) {
             error_log(I18n::_('%s requires the PATH to end in a "%s". Please update the PATH in your index.php.', I18n::_('PrivateBin'), DIRECTORY_SEPARATOR));
             error_log(I18n::_('%s requires the PATH to end in a "%s". Please update the PATH in your index.php.', I18n::_('PrivateBin'), DIRECTORY_SEPARATOR));
             return;
             return;
         }
         }
@@ -159,6 +159,9 @@ class Controller
             case 'shlinkproxy':
             case 'shlinkproxy':
                 $this->_shortenerproxy(new ShlinkProxy($this->_conf, $this->_request->getParam('link')));
                 $this->_shortenerproxy(new ShlinkProxy($this->_conf, $this->_request->getParam('link')));
                 break;
                 break;
+            case 'chhotoproxy':
+                $this->_shortenerproxy(new ChhotoProxy($this->_conf, $this->_request->getParam('link')));
+                break;
         }
         }
 
 
         $this->_setCacheHeaders();
         $this->_setCacheHeaders();
@@ -169,6 +172,7 @@ class Controller
             header('Access-Control-Allow-Origin: *');
             header('Access-Control-Allow-Origin: *');
             header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE');
             header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE');
             header('Access-Control-Allow-Headers: X-Requested-With, Content-Type');
             header('Access-Control-Allow-Headers: X-Requested-With, Content-Type');
+            header('X-Content-Type-Options: nosniff');
             header('X-Uncompressed-Content-Length: ' . strlen($this->_json));
             header('X-Uncompressed-Content-Length: ' . strlen($this->_json));
             header('Access-Control-Expose-Headers: X-Uncompressed-Content-Length');
             header('Access-Control-Expose-Headers: X-Uncompressed-Content-Length');
             echo $this->_json;
             echo $this->_json;
@@ -204,9 +208,12 @@ class Controller
         $lang = $this->_conf->getKey('languagedefault');
         $lang = $this->_conf->getKey('languagedefault');
         I18n::setLanguageFallback($lang);
         I18n::setLanguageFallback($lang);
         // force default language, if language selection is disabled and a default is set
         // force default language, if language selection is disabled and a default is set
-        if (!$this->_conf->getKey('languageselection') && strlen($lang) === 2) {
+        if (!$this->_conf->getKey('languageselection') && (
+            in_array($lang, I18n::getAvailableLanguages(), true) ||
+            $lang === 'en'
+        )) {
             $_COOKIE['lang'] = $lang;
             $_COOKIE['lang'] = $lang;
-            setcookie('lang', $lang, array('SameSite' => 'Lax', 'Secure' => true));
+            setcookie('lang', $lang, ['SameSite' => 'Lax', 'Secure' => true]);
         }
         }
     }
     }
 
 
@@ -230,7 +237,7 @@ class Controller
         if (!$this->_conf->getKey('templateselection') && array_key_exists('template', $_COOKIE)) {
         if (!$this->_conf->getKey('templateselection') && array_key_exists('template', $_COOKIE)) {
             unset($_COOKIE['template']); // ensure value is not re-used in template switcher
             unset($_COOKIE['template']); // ensure value is not re-used in template switcher
             $expiredInAllTimezones = time() - 86400;
             $expiredInAllTimezones = time() - 86400;
-            setcookie('template', '', array('expires' => $expiredInAllTimezones, 'SameSite' => 'Lax', 'Secure' => true));
+            setcookie('template', '', ['expires' => $expiredInAllTimezones, 'SameSite' => 'Lax', 'Secure' => true]);
         }
         }
     }
     }
 
 
@@ -324,7 +331,7 @@ class Controller
                 $paste = $this->_model->getPaste();
                 $paste = $this->_model->getPaste();
                 $paste->setData($data);
                 $paste->setData($data);
                 $paste->store();
                 $paste->store();
-                $this->_json_result($paste->getId(), array('deletetoken' => $paste->getDeleteToken()));
+                $this->_json_result($paste->getId(), ['deletetoken' => $paste->getDeleteToken()]);
             } catch (Exception $e) {
             } catch (Exception $e) {
                 $this->_json_error($e->getMessage());
                 $this->_json_error($e->getMessage());
             }
             }
@@ -418,7 +425,7 @@ class Controller
         header('X-Frame-Options: deny');
         header('X-Frame-Options: deny');
 
 
         // label all the expiration options
         // label all the expiration options
-        $expire = array();
+        $expire = [];
         foreach ($this->_conf->getSection('expire_options') as $time => $seconds) {
         foreach ($this->_conf->getSection('expire_options') as $time => $seconds) {
             $expire[$time] = ($seconds === 0) ? I18n::_(ucfirst($time)) : Filter::formatHumanReadableTime($time);
             $expire[$time] = ($seconds === 0) ? I18n::_(ucfirst($time)) : Filter::formatHumanReadableTime($time);
         }
         }
@@ -430,22 +437,22 @@ class Controller
         $languageselection = '';
         $languageselection = '';
         if ($this->_conf->getKey('languageselection')) {
         if ($this->_conf->getKey('languageselection')) {
             $languageselection = I18n::getLanguage();
             $languageselection = I18n::getLanguage();
-            setcookie('lang', $languageselection, array('SameSite' => 'Lax', 'Secure' => true));
+            setcookie('lang', $languageselection, ['SameSite' => 'Lax', 'Secure' => true]);
         }
         }
 
 
         // set template cookie if that functionality was enabled
         // set template cookie if that functionality was enabled
         $templateselection = '';
         $templateselection = '';
         if ($this->_conf->getKey('templateselection')) {
         if ($this->_conf->getKey('templateselection')) {
             $templateselection = TemplateSwitcher::getTemplate();
             $templateselection = TemplateSwitcher::getTemplate();
-            setcookie('template', $templateselection, array('SameSite' => 'Lax', 'Secure' => true));
+            setcookie('template', $templateselection, ['SameSite' => 'Lax', 'Secure' => true]);
         }
         }
 
 
         // strip policies that are unsupported in meta tag
         // strip policies that are unsupported in meta tag
         $metacspheader = str_replace(
         $metacspheader = str_replace(
-            array(
+            [
                 'frame-ancestors \'none\'; ',
                 'frame-ancestors \'none\'; ',
                 '; sandbox allow-same-origin allow-scripts allow-forms allow-modals allow-downloads',
                 '; sandbox allow-same-origin allow-scripts allow-forms allow-modals allow-downloads',
-            ),
+            ],
             '',
             '',
             $this->_conf->getKey('cspheader')
             $this->_conf->getKey('cspheader')
         );
         );
@@ -454,7 +461,7 @@ class Controller
         $page->assign('CSPHEADER', $metacspheader);
         $page->assign('CSPHEADER', $metacspheader);
         $page->assign('ERROR', I18n::_($this->_error));
         $page->assign('ERROR', I18n::_($this->_error));
         $page->assign('NAME', $this->_conf->getKey('name'));
         $page->assign('NAME', $this->_conf->getKey('name'));
-        if (in_array($this->_request->getOperation(), array('shlinkproxy', 'yourlsproxy'), true)) {
+        if (in_array($this->_request->getOperation(), ['shlinkproxy', 'yourlsproxy', 'chhotoproxy'], true)) {
             $page->assign('SHORTURL', $this->_status);
             $page->assign('SHORTURL', $this->_status);
             $page->draw('shortenerproxy');
             $page->draw('shortenerproxy');
             return;
             return;
@@ -500,13 +507,13 @@ class Controller
      */
      */
     private function _jsonld($type)
     private function _jsonld($type)
     {
     {
-        if (!in_array($type, array(
+        if (!in_array($type, [
             'comment',
             'comment',
             'commentmeta',
             'commentmeta',
             'paste',
             'paste',
             'pastemeta',
             'pastemeta',
             'types',
             'types',
-        ))) {
+        ])) {
             $type = '';
             $type = '';
         }
         }
         $content = '{}';
         $content = '{}';
@@ -514,7 +521,7 @@ class Controller
         if (is_readable($file)) {
         if (is_readable($file)) {
             $content = str_replace(
             $content = str_replace(
                 '?jsonld=',
                 '?jsonld=',
-                $this->_urlBase . '?jsonld=',
+                trim(Json::encode($this->_urlBase), '"') . '?jsonld=',
                 file_get_contents($file)
                 file_get_contents($file)
             );
             );
         }
         }
@@ -529,6 +536,7 @@ class Controller
         header('Content-type: application/ld+json');
         header('Content-type: application/ld+json');
         header('Access-Control-Allow-Origin: *');
         header('Access-Control-Allow-Origin: *');
         header('Access-Control-Allow-Methods: GET');
         header('Access-Control-Allow-Methods: GET');
+        header('X-Content-Type-Options: nosniff');
         echo $content;
         echo $content;
     }
     }
 
 
@@ -541,10 +549,10 @@ class Controller
      */
      */
     private function _json_error($error)
     private function _json_error($error)
     {
     {
-        $result = array(
+        $result = [
             'status'  => 1,
             'status'  => 1,
             'message' => $error,
             'message' => $error,
-        );
+        ];
         $this->_json = Json::encode($result);
         $this->_json = Json::encode($result);
     }
     }
 
 
@@ -556,13 +564,13 @@ class Controller
      * @param  array $other
      * @param  array $other
      * @throws JsonException
      * @throws JsonException
      */
      */
-    private function _json_result($dataid, $other = array())
+    private function _json_result($dataid, $other = [])
     {
     {
-        $result = array(
+        $result = [
             'status' => 0,
             'status' => 0,
             'id'     => $dataid,
             'id'     => $dataid,
             'url'    => $this->_urlBase . '?' . $dataid,
             'url'    => $this->_urlBase . '?' . $dataid,
-        ) + $other;
+        ] + $other;
         $this->_json = Json::encode($result);
         $this->_json = Json::encode($result);
     }
     }
 
 

+ 14 - 1
lib/Data/AbstractData.php

@@ -24,7 +24,7 @@ abstract class AbstractData
      * @access protected
      * @access protected
      * @var    array
      * @var    array
      */
      */
-    protected $_last_cache = array();
+    protected $_last_cache = [];
 
 
     /**
     /**
      * Create a paste.
      * Create a paste.
@@ -195,4 +195,17 @@ abstract class AbstractData
         }
         }
         return $created;
         return $created;
     }
     }
+
+    /**
+     * Sort comments chronologically, including collision suffixes.
+     *
+     * @access protected
+     * @param  array $comments
+     * @return array
+     */
+    protected function sortComments(array $comments)
+    {
+        ksort($comments, SORT_NATURAL);
+        return $comments;
+    }
 }
 }

+ 29 - 30
lib/Data/Database.php

@@ -12,10 +12,10 @@
 namespace PrivateBin\Data;
 namespace PrivateBin\Data;
 
 
 use Exception;
 use Exception;
+use JsonException;
 use PDO;
 use PDO;
 use PDOException;
 use PDOException;
 use PrivateBin\Controller;
 use PrivateBin\Controller;
-use PrivateBin\Exception\JsonException;
 use PrivateBin\Json;
 use PrivateBin\Json;
 
 
 /**
 /**
@@ -158,12 +158,12 @@ class Database extends AbstractData
             return $this->_exec(
             return $this->_exec(
                 'INSERT INTO "' . $this->_sanitizeIdentifier('paste') .
                 'INSERT INTO "' . $this->_sanitizeIdentifier('paste') .
                 '" VALUES(?,?,?,?)',
                 '" VALUES(?,?,?,?)',
-                array(
+                [
                     $pasteid,
                     $pasteid,
                     Json::encode($paste),
                     Json::encode($paste),
                     $expire_date,
                     $expire_date,
                     Json::encode($meta),
                     Json::encode($meta),
-                )
+                ]
             );
             );
         } catch (Exception $e) {
         } catch (Exception $e) {
             error_log('Error while attempting to insert a paste into the database: ' . $e->getMessage());
             error_log('Error while attempting to insert a paste into the database: ' . $e->getMessage());
@@ -183,7 +183,7 @@ class Database extends AbstractData
         try {
         try {
             $row = $this->_select(
             $row = $this->_select(
                 'SELECT * FROM "' . $this->_sanitizeIdentifier('paste') .
                 'SELECT * FROM "' . $this->_sanitizeIdentifier('paste') .
-                '" WHERE "dataid" = ?', array($pasteid), true
+                '" WHERE "dataid" = ?', [$pasteid], true
             );
             );
         } catch (PDOException $e) {
         } catch (PDOException $e) {
             $row = false;
             $row = false;
@@ -196,14 +196,14 @@ class Database extends AbstractData
             $paste = Json::decode($row['data']);
             $paste = Json::decode($row['data']);
         } catch (JsonException $e) {
         } catch (JsonException $e) {
             error_log('Error while reading a paste from the database: ' . $e->getMessage());
             error_log('Error while reading a paste from the database: ' . $e->getMessage());
-            $paste = array();
+            $paste = [];
         }
         }
 
 
         try {
         try {
             $paste['meta'] = Json::decode($row['meta']);
             $paste['meta'] = Json::decode($row['meta']);
         } catch (JsonException $e) {
         } catch (JsonException $e) {
             error_log('Error while reading a paste from the database: ' . $e->getMessage());
             error_log('Error while reading a paste from the database: ' . $e->getMessage());
-            $paste['meta'] = array();
+            $paste['meta'] = [];
         }
         }
         $expire_date = (int) $row['expiredate'];
         $expire_date = (int) $row['expiredate'];
         if ($expire_date > 0) {
         if ($expire_date > 0) {
@@ -223,11 +223,11 @@ class Database extends AbstractData
     {
     {
         $this->_exec(
         $this->_exec(
             'DELETE FROM "' . $this->_sanitizeIdentifier('paste') .
             'DELETE FROM "' . $this->_sanitizeIdentifier('paste') .
-            '" WHERE "dataid" = ?', array($pasteid)
+            '" WHERE "dataid" = ?', [$pasteid]
         );
         );
         $this->_exec(
         $this->_exec(
             'DELETE FROM "' . $this->_sanitizeIdentifier('comment') .
             'DELETE FROM "' . $this->_sanitizeIdentifier('comment') .
-            '" WHERE "pasteid" = ?', array($pasteid)
+            '" WHERE "pasteid" = ?', [$pasteid]
         );
         );
     }
     }
 
 
@@ -243,7 +243,7 @@ class Database extends AbstractData
         try {
         try {
             $row = $this->_select(
             $row = $this->_select(
                 'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('paste') .
                 'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('paste') .
-                '" WHERE "dataid" = ?', array($pasteid), true
+                '" WHERE "dataid" = ?', [$pasteid], true
             );
             );
         } catch (PDOException $e) {
         } catch (PDOException $e) {
             return false;
             return false;
@@ -277,14 +277,14 @@ class Database extends AbstractData
             return $this->_exec(
             return $this->_exec(
                 'INSERT INTO "' . $this->_sanitizeIdentifier('comment') .
                 'INSERT INTO "' . $this->_sanitizeIdentifier('comment') .
                 '" VALUES(?,?,?,?,?,?)',
                 '" VALUES(?,?,?,?,?,?)',
-                array(
+                [
                     $commentid,
                     $commentid,
                     $pasteid,
                     $pasteid,
                     $parentid,
                     $parentid,
                     $data,
                     $data,
                     $meta['icon'],
                     $meta['icon'],
                     $meta['created'],
                     $meta['created'],
-                )
+                ]
             );
             );
         } catch (PDOException $e) {
         } catch (PDOException $e) {
             error_log('Error while attempting to insert a comment into the database: ' . $e->getMessage());
             error_log('Error while attempting to insert a comment into the database: ' . $e->getMessage());
@@ -303,31 +303,30 @@ class Database extends AbstractData
     {
     {
         $rows = $this->_select(
         $rows = $this->_select(
             'SELECT * FROM "' . $this->_sanitizeIdentifier('comment') .
             'SELECT * FROM "' . $this->_sanitizeIdentifier('comment') .
-            '" WHERE "pasteid" = ?', array($pasteid)
+            '" WHERE "pasteid" = ?', [$pasteid]
         );
         );
 
 
         // create comment list
         // create comment list
-        $comments = array();
+        $comments = [];
         if (count($rows)) {
         if (count($rows)) {
             foreach ($rows as $row) {
             foreach ($rows as $row) {
                 try {
                 try {
                     $data = Json::decode($row['data']);
                     $data = Json::decode($row['data']);
                 } catch (JsonException $e) {
                 } catch (JsonException $e) {
                     error_log('Error while reading a comment from the database: ' . $e->getMessage());
                     error_log('Error while reading a comment from the database: ' . $e->getMessage());
-                    $data = array();
+                    $data = [];
                 }
                 }
                 $i                          = $this->getOpenSlot($comments, (int) $row['postdate']);
                 $i                          = $this->getOpenSlot($comments, (int) $row['postdate']);
                 $comments[$i]               = $data;
                 $comments[$i]               = $data;
                 $comments[$i]['id']         = $row['dataid'];
                 $comments[$i]['id']         = $row['dataid'];
                 $comments[$i]['parentid']   = $row['parentid'];
                 $comments[$i]['parentid']   = $row['parentid'];
-                $comments[$i]['meta']       = array('created' => (int) $row['postdate']);
+                $comments[$i]['meta']       = ['created' => (int) $row['postdate']];
                 if (array_key_exists('vizhash', $row) && !empty($row['vizhash'])) {
                 if (array_key_exists('vizhash', $row) && !empty($row['vizhash'])) {
                     $comments[$i]['meta']['icon'] = $row['vizhash'];
                     $comments[$i]['meta']['icon'] = $row['vizhash'];
                 }
                 }
             }
             }
-            ksort($comments);
         }
         }
-        return $comments;
+        return $this->sortComments($comments);
     }
     }
 
 
     /**
     /**
@@ -345,7 +344,7 @@ class Database extends AbstractData
             return (bool) $this->_select(
             return (bool) $this->_select(
                 'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('comment') .
                 'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('comment') .
                 '" WHERE "pasteid" = ? AND "parentid" = ? AND "dataid" = ?',
                 '" WHERE "pasteid" = ? AND "parentid" = ? AND "dataid" = ?',
-                array($pasteid, $parentid, $commentid), true
+                [$pasteid, $parentid, $commentid], true
             );
             );
         } catch (PDOException $e) {
         } catch (PDOException $e) {
             return false;
             return false;
@@ -375,7 +374,7 @@ class Database extends AbstractData
         return $this->_exec(
         return $this->_exec(
             'UPDATE "' . $this->_sanitizeIdentifier('config') .
             'UPDATE "' . $this->_sanitizeIdentifier('config') .
             '" SET "value" = ? WHERE "id" = ?',
             '" SET "value" = ? WHERE "id" = ?',
-            array($value, strtoupper($namespace))
+            [$value, strtoupper($namespace)]
         );
         );
     }
     }
 
 
@@ -396,13 +395,13 @@ class Database extends AbstractData
             $this->_exec(
             $this->_exec(
                 'INSERT INTO "' . $this->_sanitizeIdentifier('config') .
                 'INSERT INTO "' . $this->_sanitizeIdentifier('config') .
                 '" VALUES(?,?)',
                 '" VALUES(?,?)',
-                array($configKey, '')
+                [$configKey, '']
             );
             );
 
 
             // migrate filesystem based salt into database
             // migrate filesystem based salt into database
             $file = 'data' . DIRECTORY_SEPARATOR . 'salt.php';
             $file = 'data' . DIRECTORY_SEPARATOR . 'salt.php';
             if ($namespace === 'salt' && is_readable($file)) {
             if ($namespace === 'salt' && is_readable($file)) {
-                $fs    = new Filesystem(array('dir' => 'data'));
+                $fs    = new Filesystem(['dir' => 'data']);
                 $value = $fs->getValue('salt');
                 $value = $fs->getValue('salt');
                 $this->setValue($value, 'salt');
                 $this->setValue($value, 'salt');
                 if (!unlink($file)) {
                 if (!unlink($file)) {
@@ -416,7 +415,7 @@ class Database extends AbstractData
                 $this->_last_cache = Json::decode($value);
                 $this->_last_cache = Json::decode($value);
             } catch (JsonException $e) {
             } catch (JsonException $e) {
                 error_log('Error decoding JSON from table "config", row "traffic_limiter": ' . $e->getMessage());
                 error_log('Error decoding JSON from table "config", row "traffic_limiter": ' . $e->getMessage());
-                $this->_last_cache = array();
+                $this->_last_cache = [];
             }
             }
             if (array_key_exists($key, $this->_last_cache)) {
             if (array_key_exists($key, $this->_last_cache)) {
                 return $this->_last_cache[$key];
                 return $this->_last_cache[$key];
@@ -440,11 +439,11 @@ class Database extends AbstractData
                 '" WHERE "expiredate" < ? AND "expiredate" != ? ' .
                 '" WHERE "expiredate" < ? AND "expiredate" != ? ' .
                 ($this->_type === 'oci' ? 'FETCH NEXT ? ROWS ONLY' : 'LIMIT ?')
                 ($this->_type === 'oci' ? 'FETCH NEXT ? ROWS ONLY' : 'LIMIT ?')
             );
             );
-            $statement->execute(array(time(), 0, $batchsize));
+            $statement->execute([time(), 0, $batchsize]);
             return $statement->fetchAll(PDO::FETCH_COLUMN, 0);
             return $statement->fetchAll(PDO::FETCH_COLUMN, 0);
         } catch (PDOException $e) {
         } catch (PDOException $e) {
             error_log('Error while attempting to find expired pastes in the database: ' . $e->getMessage());
             error_log('Error while attempting to find expired pastes in the database: ' . $e->getMessage());
-            return array();
+            return [];
         }
         }
     }
     }
 
 
@@ -508,7 +507,7 @@ class Database extends AbstractData
             }
             }
         } elseif ($this->_type === 'oci') {
         } elseif ($this->_type === 'oci') {
             // workaround for https://bugs.php.net/bug.php?id=46728
             // workaround for https://bugs.php.net/bug.php?id=46728
-            $result = array();
+            $result = [];
             while ($row = $statement->fetch(PDO::FETCH_ASSOC)) {
             while ($row = $statement->fetch(PDO::FETCH_ASSOC)) {
                 $result[] = array_map('PrivateBin\Data\Database::_sanitizeClob', $row);
                 $result[] = array_map('PrivateBin\Data\Database::_sanitizeClob', $row);
             }
             }
@@ -576,7 +575,7 @@ class Database extends AbstractData
         try {
         try {
             $row = $this->_select(
             $row = $this->_select(
                 'SELECT "value" FROM "' . $this->_sanitizeIdentifier('config') .
                 'SELECT "value" FROM "' . $this->_sanitizeIdentifier('config') .
-                '" WHERE "id" = ?', array($key), true
+                '" WHERE "id" = ?', [$key], true
             );
             );
         } catch (PDOException $e) {
         } catch (PDOException $e) {
             error_log('Error while attempting to fetch configuration key "' . $key . '" in the database: ' . $e->getMessage());
             error_log('Error while attempting to fetch configuration key "' . $key . '" in the database: ' . $e->getMessage());
@@ -604,7 +603,7 @@ class Database extends AbstractData
                 $main_key = ' PRIMARY KEY';
                 $main_key = ' PRIMARY KEY';
                 break;
                 break;
         }
         }
-        return array($main_key, $after_key);
+        return [$main_key, $after_key];
     }
     }
 
 
     /**
     /**
@@ -743,7 +742,7 @@ class Database extends AbstractData
         $this->_exec(
         $this->_exec(
             'INSERT INTO "' . $this->_sanitizeIdentifier('config') .
             'INSERT INTO "' . $this->_sanitizeIdentifier('config') .
             '" VALUES(?,?)',
             '" VALUES(?,?)',
-            array('VERSION', Controller::VERSION)
+            ['VERSION', Controller::VERSION]
         );
         );
     }
     }
 
 
@@ -788,7 +787,7 @@ class Database extends AbstractData
         $supportsDropColumn = true;
         $supportsDropColumn = true;
         if ($this->_type === 'sqlite') {
         if ($this->_type === 'sqlite') {
             try {
             try {
-                $row                = $this->_select('SELECT sqlite_version() AS "v"', array(), true);
+                $row                = $this->_select('SELECT sqlite_version() AS "v"', [], true);
                 $supportsDropColumn = (bool) version_compare($row['v'], '3.35.0', '>=');
                 $supportsDropColumn = (bool) version_compare($row['v'], '3.35.0', '>=');
             } catch (PDOException $e) {
             } catch (PDOException $e) {
                 $supportsDropColumn = false;
                 $supportsDropColumn = false;
@@ -902,7 +901,7 @@ class Database extends AbstractData
         $this->_exec(
         $this->_exec(
             'UPDATE "' . $this->_sanitizeIdentifier('config') .
             'UPDATE "' . $this->_sanitizeIdentifier('config') .
             '" SET "value" = ? WHERE "id" = ?',
             '" SET "value" = ? WHERE "id" = ?',
-            array(Controller::VERSION, 'VERSION')
+            [Controller::VERSION, 'VERSION']
         );
         );
     }
     }
 }
 }

+ 54 - 24
lib/Data/Filesystem.php

@@ -13,7 +13,7 @@ namespace PrivateBin\Data;
 
 
 use DirectoryIterator;
 use DirectoryIterator;
 use GlobIterator;
 use GlobIterator;
-use PrivateBin\Exception\JsonException;
+use JsonException;
 use PrivateBin\Json;
 use PrivateBin\Json;
 
 
 /**
 /**
@@ -23,6 +23,23 @@ use PrivateBin\Json;
  */
  */
 class Filesystem extends AbstractData
 class Filesystem extends AbstractData
 {
 {
+    /**
+     * line in generated .htaccess files, to protect exposed directories from being browsable on apache web servers
+     *
+     * @const string
+     */
+    const HTACCESS_LINE = 'Require all denied';
+
+    /**
+     * ID glob() pattern of valid document IDs as well as parent and comment IDs
+     * 16 lower case characters for 8 bytes in hexadecimal encoding
+     *
+     * @const string
+     */
+    const ID_PATTERN =
+        '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]' .
+        '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]';
+
     /**
     /**
      * glob() pattern of the two folder levels and the paste files under the
      * glob() pattern of the two folder levels and the paste files under the
      * configured path. Needs to return both files with and without .php suffix,
      * configured path. Needs to return both files with and without .php suffix,
@@ -36,8 +53,7 @@ class Filesystem extends AbstractData
      */
      */
     const PASTE_FILE_PATTERN = DIRECTORY_SEPARATOR . '[a-f0-9][a-f0-9]' .
     const PASTE_FILE_PATTERN = DIRECTORY_SEPARATOR . '[a-f0-9][a-f0-9]' .
         DIRECTORY_SEPARATOR . '[a-f0-9][a-f0-9]' . DIRECTORY_SEPARATOR .
         DIRECTORY_SEPARATOR . '[a-f0-9][a-f0-9]' . DIRECTORY_SEPARATOR .
-        '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]' .
-        '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]*';
+        self::ID_PATTERN . '*';
 
 
     /**
     /**
      * first line in paste or comment files, to protect their contents from browsing exposed data directories
      * first line in paste or comment files, to protect their contents from browsing exposed data directories
@@ -46,13 +62,6 @@ class Filesystem extends AbstractData
      */
      */
     const PROTECTION_LINE = '<?php http_response_code(403); /*';
     const PROTECTION_LINE = '<?php http_response_code(403); /*';
 
 
-    /**
-     * line in generated .htaccess files, to protect exposed directories from being browsable on apache web servers
-     *
-     * @const string
-     */
-    const HTACCESS_LINE = 'Require all denied';
-
     /**
     /**
      * path in which to persist something
      * path in which to persist something
      *
      *
@@ -163,8 +172,10 @@ class Filesystem extends AbstractData
             // convert comments, too
             // convert comments, too
             $discdir = $this->_dataid2discussionpath($pasteid);
             $discdir = $this->_dataid2discussionpath($pasteid);
             if (is_dir($discdir)) {
             if (is_dir($discdir)) {
-                foreach (new DirectoryIterator($discdir) as $file) {
-                    if ($file->getExtension() !== 'php' && strlen($file->getFilename()) >= 16) {
+                foreach (new GlobIterator($discdir . DIRECTORY_SEPARATOR . $pasteid .
+                    '.' . self::ID_PATTERN . '.' . self::ID_PATTERN) as $file
+                ) {
+                    if ($file->isFile()) {
                         $this->_prependRename($file->getPathname(), $file->getPathname() . '.php');
                         $this->_prependRename($file->getPathname(), $file->getPathname() . '.php');
                     }
                     }
                 }
                 }
@@ -205,17 +216,26 @@ class Filesystem extends AbstractData
      */
      */
     public function readComments($pasteid)
     public function readComments($pasteid)
     {
     {
-        $comments = array();
+        $comments = [];
         $discdir  = $this->_dataid2discussionpath($pasteid);
         $discdir  = $this->_dataid2discussionpath($pasteid);
         if (is_dir($discdir)) {
         if (is_dir($discdir)) {
-            foreach (new DirectoryIterator($discdir) as $file) {
+            foreach (new GlobIterator($discdir . DIRECTORY_SEPARATOR . $pasteid .
+                '.' . self::ID_PATTERN . '.' . self::ID_PATTERN . '.php') as $file
+            ) {
                 // Filename is in the form pasteid.commentid.parentid.php:
                 // Filename is in the form pasteid.commentid.parentid.php:
                 // - pasteid is the paste this reply belongs to.
                 // - pasteid is the paste this reply belongs to.
                 // - commentid is the comment identifier itself.
                 // - commentid is the comment identifier itself.
                 // - parentid is the comment this comment replies to (It can be pasteid)
                 // - parentid is the comment this comment replies to (It can be pasteid)
                 if ($file->isFile()) {
                 if ($file->isFile()) {
                     $comment = $this->_get($file->getPathname());
                     $comment = $this->_get($file->getPathname());
-                    $items   = explode('.', $file->getBasename('.php'));
+                    if (
+                        !is_array($comment) ||
+                        !isset($comment['meta']['created']) ||
+                        !(is_int($comment['meta']['created']) || is_string($comment['meta']['created']))
+                    ) {
+                        continue;
+                    }
+                    $items = explode('.', $file->getBasename('.php'));
                     // Add some meta information not contained in file.
                     // Add some meta information not contained in file.
                     $comment['id']       = $items[1];
                     $comment['id']       = $items[1];
                     $comment['parentid'] = $items[2];
                     $comment['parentid'] = $items[2];
@@ -228,11 +248,8 @@ class Filesystem extends AbstractData
                     $comments[$key] = $comment;
                     $comments[$key] = $comment;
                 }
                 }
             }
             }
-
-            // Sort comments by date, oldest first.
-            ksort($comments);
         }
         }
-        return $comments;
+        return $this->sortComments($comments);
     }
     }
 
 
     /**
     /**
@@ -359,7 +376,7 @@ class Filesystem extends AbstractData
      */
      */
     protected function _getExpiredPastes($batchsize)
     protected function _getExpiredPastes($batchsize)
     {
     {
-        $pastes = array();
+        $pastes = [];
         $count  = 0;
         $count  = 0;
         $opened = 0;
         $opened = 0;
         $limit  = $batchsize * 10; // try at most 10 times $batchsize pastes before giving up
         $limit  = $batchsize * 10; // try at most 10 times $batchsize pastes before giving up
@@ -388,7 +405,7 @@ class Filesystem extends AbstractData
      */
      */
     public function getAllPastes()
     public function getAllPastes()
     {
     {
-        $pastes = array();
+        $pastes = [];
         foreach (new GlobIterator($this->_path . self::PASTE_FILE_PATTERN) as $file) {
         foreach (new GlobIterator($this->_path . self::PASTE_FILE_PATTERN) as $file) {
             if ($file->isFile()) {
             if ($file->isFile()) {
                 $pastes[] = $file->getBasename('.php');
                 $pastes[] = $file->getBasename('.php');
@@ -516,10 +533,23 @@ class Filesystem extends AbstractData
     {
     {
         // don't overwrite already converted file
         // don't overwrite already converted file
         if (!is_readable($destFile)) {
         if (!is_readable($destFile)) {
-            $handle = fopen($srcFile, 'r', false, stream_context_create());
-            file_put_contents($destFile, self::PROTECTION_LINE . PHP_EOL);
-            file_put_contents($destFile, $handle, FILE_APPEND);
+            $handle = @fopen($srcFile, 'r', false, stream_context_create());
+            if ($handle === false) {
+                error_log('Error reading to be converted document: ' . $srcFile);
+                return;
+            }
+            $written = @file_put_contents($destFile, self::PROTECTION_LINE . PHP_EOL);
+            if ($written !== false) {
+                $written = @file_put_contents($destFile, $handle, FILE_APPEND);
+            }
+            if ($written === false) {
+                error_log('Error writing converted document: ' . $destFile);
+            }
             fclose($handle);
             fclose($handle);
+            chmod($destFile, 0640); // protect file from access by other users on the host
+            if ($written === false) {
+                return;
+            }
         }
         }
         if (!unlink($srcFile)) {
         if (!unlink($srcFile)) {
             error_log('Error deleting converted document: ' . $srcFile);
             error_log('Error deleting converted document: ' . $srcFile);

+ 23 - 23
lib/Data/GoogleCloudStorage.php

@@ -15,7 +15,7 @@ use Exception;
 use Google\Cloud\Core\Exception\NotFoundException;
 use Google\Cloud\Core\Exception\NotFoundException;
 use Google\Cloud\Storage\Bucket;
 use Google\Cloud\Storage\Bucket;
 use Google\Cloud\Storage\StorageClient;
 use Google\Cloud\Storage\StorageClient;
-use PrivateBin\Exception\JsonException;
+use JsonException;
 use PrivateBin\Json;
 use PrivateBin\Json;
 
 
 class GoogleCloudStorage extends AbstractData
 class GoogleCloudStorage extends AbstractData
@@ -74,8 +74,8 @@ class GoogleCloudStorage extends AbstractData
         }
         }
 
 
         $this->_client = class_exists('StorageClientStub', false) ?
         $this->_client = class_exists('StorageClientStub', false) ?
-            new \StorageClientStub(array()) :
-            new StorageClient(array('suppressKeyFileNotice' => true));
+            new \StorageClientStub([]) :
+            new StorageClient(['suppressKeyFileNotice' => true]);
         if (isset($bucket)) {
         if (isset($bucket)) {
             $this->_bucket = $this->_client->bucket($bucket);
             $this->_bucket = $this->_client->bucket($bucket);
         }
         }
@@ -107,20 +107,20 @@ class GoogleCloudStorage extends AbstractData
      */
      */
     private function _upload($key, &$payload)
     private function _upload($key, &$payload)
     {
     {
-        $metadata = $payload['meta'] ?? array();
+        $metadata = $payload['meta'] ?? [];
         unset($metadata['salt']);
         unset($metadata['salt']);
         foreach ($metadata as $k => $v) {
         foreach ($metadata as $k => $v) {
             $metadata[$k] = strval($v);
             $metadata[$k] = strval($v);
         }
         }
         try {
         try {
-            $data = array(
+            $data = [
                 'name'          => $key,
                 'name'          => $key,
                 'chunkSize'     => 262144,
                 'chunkSize'     => 262144,
-                'metadata'      => array(
+                'metadata'      => [
                     'content-type' => 'application/json',
                     'content-type' => 'application/json',
                     'metadata'     => $metadata,
                     'metadata'     => $metadata,
-                ),
-            );
+                ],
+            ];
             if (!$this->_uniformacl) {
             if (!$this->_uniformacl) {
                 $data['predefinedAcl'] = 'private';
                 $data['predefinedAcl'] = 'private';
             }
             }
@@ -171,7 +171,7 @@ class GoogleCloudStorage extends AbstractData
         $name = $this->_getKey($pasteid);
         $name = $this->_getKey($pasteid);
 
 
         try {
         try {
-            foreach ($this->_bucket->objects(array('prefix' => $name . '/discussion/')) as $comment) {
+            foreach ($this->_bucket->objects(['prefix' => $name . '/discussion/']) as $comment) {
                 try {
                 try {
                     $this->_bucket->object($comment->name())->delete();
                     $this->_bucket->object($comment->name())->delete();
                 } catch (NotFoundException $e) {
                 } catch (NotFoundException $e) {
@@ -215,16 +215,16 @@ class GoogleCloudStorage extends AbstractData
      */
      */
     public function readComments($pasteid)
     public function readComments($pasteid)
     {
     {
-        $comments = array();
+        $comments = [];
         $prefix   = $this->_getKey($pasteid) . '/discussion/';
         $prefix   = $this->_getKey($pasteid) . '/discussion/';
         try {
         try {
-            foreach ($this->_bucket->objects(array('prefix' => $prefix)) as $key) {
+            foreach ($this->_bucket->objects(['prefix' => $prefix]) as $key) {
                 $data            = $this->_bucket->object($key->name())->downloadAsString();
                 $data            = $this->_bucket->object($key->name())->downloadAsString();
                 try {
                 try {
                     $comment = Json::decode($data);
                     $comment = Json::decode($data);
                 } catch (JsonException $e) {
                 } catch (JsonException $e) {
                     error_log('failed to read comment from ' . $key->name() . ', ' . $e->getMessage());
                     error_log('failed to read comment from ' . $key->name() . ', ' . $e->getMessage());
-                    $comment = array();
+                    $comment = [];
                 }
                 }
                 $comment['id']   = basename($key->name());
                 $comment['id']   = basename($key->name());
                 $slot            = $this->getOpenSlot($comments, (int) $comment['meta']['created']);
                 $slot            = $this->getOpenSlot($comments, (int) $comment['meta']['created']);
@@ -233,7 +233,7 @@ class GoogleCloudStorage extends AbstractData
         } catch (NotFoundException $e) {
         } catch (NotFoundException $e) {
             // no comments found
             // no comments found
         }
         }
-        return $comments;
+        return $this->sortComments($comments);
     }
     }
 
 
     /**
     /**
@@ -253,7 +253,7 @@ class GoogleCloudStorage extends AbstractData
     {
     {
         $path = 'config/' . $namespace;
         $path = 'config/' . $namespace;
         try {
         try {
-            foreach ($this->_bucket->objects(array('prefix' => $path)) as $object) {
+            foreach ($this->_bucket->objects(['prefix' => $path]) as $object) {
                 $name = $object->name();
                 $name = $object->name();
                 if (strlen($name) > strlen($path) && substr($name, strlen($path), 1) !== '/') {
                 if (strlen($name) > strlen($path) && substr($name, strlen($path), 1) !== '/') {
                     continue;
                     continue;
@@ -285,19 +285,19 @@ class GoogleCloudStorage extends AbstractData
             $key = 'config/' . $namespace . '/' . $key;
             $key = 'config/' . $namespace . '/' . $key;
         }
         }
 
 
-        $metadata = array('namespace' => $namespace);
+        $metadata = ['namespace' => $namespace];
         if ($namespace !== 'salt') {
         if ($namespace !== 'salt') {
             $metadata['value'] = strval($value);
             $metadata['value'] = strval($value);
         }
         }
         try {
         try {
-            $data = array(
+            $data = [
                 'name'          => $key,
                 'name'          => $key,
                 'chunkSize'     => 262144,
                 'chunkSize'     => 262144,
-                'metadata'      => array(
+                'metadata'      => [
                     'content-type' => 'application/json',
                     'content-type' => 'application/json',
                     'metadata'     => $metadata,
                     'metadata'     => $metadata,
-                ),
-            );
+                ],
+            ];
             if (!$this->_uniformacl) {
             if (!$this->_uniformacl) {
                 $data['predefinedAcl'] = 'private';
                 $data['predefinedAcl'] = 'private';
             }
             }
@@ -333,7 +333,7 @@ class GoogleCloudStorage extends AbstractData
      */
      */
     protected function _getExpiredPastes($batchsize)
     protected function _getExpiredPastes($batchsize)
     {
     {
-        $expired = array();
+        $expired = [];
 
 
         $now    = time();
         $now    = time();
         $prefix = $this->_prefix;
         $prefix = $this->_prefix;
@@ -341,7 +341,7 @@ class GoogleCloudStorage extends AbstractData
             $prefix .= '/';
             $prefix .= '/';
         }
         }
         try {
         try {
-            foreach ($this->_bucket->objects(array('prefix' => $prefix)) as $object) {
+            foreach ($this->_bucket->objects(['prefix' => $prefix]) as $object) {
                 $expire_at = $object->info()['metadata']['expire_date'] ?? '';
                 $expire_at = $object->info()['metadata']['expire_date'] ?? '';
                 if (is_numeric($expire_at) && intval($expire_at) < $now) {
                 if (is_numeric($expire_at) && intval($expire_at) < $now) {
                     array_push($expired, basename($object->name()));
                     array_push($expired, basename($object->name()));
@@ -362,14 +362,14 @@ class GoogleCloudStorage extends AbstractData
      */
      */
     public function getAllPastes()
     public function getAllPastes()
     {
     {
-        $pastes = array();
+        $pastes = [];
         $prefix = $this->_prefix;
         $prefix = $this->_prefix;
         if (!empty($prefix)) {
         if (!empty($prefix)) {
             $prefix .= '/';
             $prefix .= '/';
         }
         }
 
 
         try {
         try {
-            foreach ($this->_bucket->objects(array('prefix' => $prefix)) as $object) {
+            foreach ($this->_bucket->objects(['prefix' => $prefix]) as $object) {
                 $candidate = substr($object->name(), strlen($prefix));
                 $candidate = substr($object->name(), strlen($prefix));
                 if (!str_contains($candidate, '/')) {
                 if (!str_contains($candidate, '/')) {
                     $pastes[] = $candidate;
                     $pastes[] = $candidate;

+ 33 - 33
lib/Data/S3Storage.php

@@ -37,7 +37,7 @@ namespace PrivateBin\Data;
 
 
 use Aws\S3\Exception\S3Exception;
 use Aws\S3\Exception\S3Exception;
 use Aws\S3\S3Client;
 use Aws\S3\S3Client;
-use PrivateBin\Exception\JsonException;
+use JsonException;
 use PrivateBin\Json;
 use PrivateBin\Json;
 
 
 class S3Storage extends AbstractData
 class S3Storage extends AbstractData
@@ -56,7 +56,7 @@ class S3Storage extends AbstractData
      * @access private
      * @access private
      * @var    array
      * @var    array
      */
      */
-    private $_options = array();
+    private $_options = [];
 
 
     /**
     /**
      * S3 bucket
      * S3 bucket
@@ -85,7 +85,7 @@ class S3Storage extends AbstractData
         // AWS SDK will try to load credentials from environment if credentials are not passed via configuration
         // AWS SDK will try to load credentials from environment if credentials are not passed via configuration
         // ref: https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/guide_credentials.html#default-credential-chain
         // ref: https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/guide_credentials.html#default-credential-chain
         if (isset($options['accesskey']) && isset($options['secretkey'])) {
         if (isset($options['accesskey']) && isset($options['secretkey'])) {
-            $this->_options['credentials'] = array();
+            $this->_options['credentials'] = [];
 
 
             $this->_options['credentials']['key']    = $options['accesskey'];
             $this->_options['credentials']['key']    = $options['accesskey'];
             $this->_options['credentials']['secret'] = $options['secretkey'];
             $this->_options['credentials']['secret'] = $options['secretkey'];
@@ -121,15 +121,15 @@ class S3Storage extends AbstractData
      */
      */
     private function _listAllObjects($prefix)
     private function _listAllObjects($prefix)
     {
     {
-        $allObjects = array();
-        $options    = array(
+        $allObjects = [];
+        $options    = [
             'Bucket' => $this->_bucket,
             'Bucket' => $this->_bucket,
             'Prefix' => $prefix,
             'Prefix' => $prefix,
-        );
+        ];
 
 
         do {
         do {
             $objectsListResponse = $this->_client->listObjects($options);
             $objectsListResponse = $this->_client->listObjects($options);
-            $objects             = $objectsListResponse['Contents'] ?? array();
+            $objects             = $objectsListResponse['Contents'] ?? [];
             foreach ($objects as $object) {
             foreach ($objects as $object) {
                 $allObjects[]      = $object;
                 $allObjects[]      = $object;
                 $options['Marker'] = $object['Key'];
                 $options['Marker'] = $object['Key'];
@@ -165,19 +165,19 @@ class S3Storage extends AbstractData
      */
      */
     private function _upload($key, &$payload)
     private function _upload($key, &$payload)
     {
     {
-        $metadata = $payload['meta'] ?? array();
+        $metadata = $payload['meta'] ?? [];
         unset($metadata['salt']);
         unset($metadata['salt']);
         foreach ($metadata as $k => $v) {
         foreach ($metadata as $k => $v) {
             $metadata[$k] = strval($v);
             $metadata[$k] = strval($v);
         }
         }
         try {
         try {
-            $this->_client->putObject(array(
+            $this->_client->putObject([
                 'Bucket'      => $this->_bucket,
                 'Bucket'      => $this->_bucket,
                 'Key'         => $key,
                 'Key'         => $key,
                 'Body'        => Json::encode($payload),
                 'Body'        => Json::encode($payload),
                 'ContentType' => 'application/json',
                 'ContentType' => 'application/json',
                 'Metadata'    => $metadata,
                 'Metadata'    => $metadata,
-            ));
+            ]);
             return true;
             return true;
         } catch (S3Exception $e) {
         } catch (S3Exception $e) {
             error_log('failed to upload ' . $key . ' to ' . $this->_bucket . ', ' .
             error_log('failed to upload ' . $key . ' to ' . $this->_bucket . ', ' .
@@ -206,10 +206,10 @@ class S3Storage extends AbstractData
     public function read($pasteid)
     public function read($pasteid)
     {
     {
         try {
         try {
-            $object = $this->_client->getObject(array(
+            $object = $this->_client->getObject([
                 'Bucket' => $this->_bucket,
                 'Bucket' => $this->_bucket,
                 'Key'    => $this->_getKey($pasteid),
                 'Key'    => $this->_getKey($pasteid),
-            ));
+            ]);
             $data = $object['Body']->getContents();
             $data = $object['Body']->getContents();
             return Json::decode($data);
             return Json::decode($data);
         } catch (S3Exception $e) {
         } catch (S3Exception $e) {
@@ -232,10 +232,10 @@ class S3Storage extends AbstractData
             $comments = $this->_listAllObjects($name . '/discussion/');
             $comments = $this->_listAllObjects($name . '/discussion/');
             foreach ($comments as $comment) {
             foreach ($comments as $comment) {
                 try {
                 try {
-                    $this->_client->deleteObject(array(
+                    $this->_client->deleteObject([
                         'Bucket' => $this->_bucket,
                         'Bucket' => $this->_bucket,
                         'Key'    => $comment['Key'],
                         'Key'    => $comment['Key'],
-                    ));
+                    ]);
                 } catch (S3Exception $e) {
                 } catch (S3Exception $e) {
                     // ignore if already deleted.
                     // ignore if already deleted.
                 }
                 }
@@ -245,10 +245,10 @@ class S3Storage extends AbstractData
         }
         }
 
 
         try {
         try {
-            $this->_client->deleteObject(array(
+            $this->_client->deleteObject([
                 'Bucket' => $this->_bucket,
                 'Bucket' => $this->_bucket,
                 'Key'    => $name,
                 'Key'    => $name,
-            ));
+            ]);
         } catch (S3Exception $e) {
         } catch (S3Exception $e) {
             // ignore if already deleted
             // ignore if already deleted
         }
         }
@@ -279,15 +279,15 @@ class S3Storage extends AbstractData
      */
      */
     public function readComments($pasteid)
     public function readComments($pasteid)
     {
     {
-        $comments = array();
+        $comments = [];
         $prefix   = $this->_getKey($pasteid) . '/discussion/';
         $prefix   = $this->_getKey($pasteid) . '/discussion/';
         try {
         try {
             $entries = $this->_listAllObjects($prefix);
             $entries = $this->_listAllObjects($prefix);
             foreach ($entries as $entry) {
             foreach ($entries as $entry) {
-                $object = $this->_client->getObject(array(
+                $object = $this->_client->getObject([
                     'Bucket' => $this->_bucket,
                     'Bucket' => $this->_bucket,
                     'Key'    => $entry['Key'],
                     'Key'    => $entry['Key'],
-                ));
+                ]);
                 $data             = $object['Body']->getContents();
                 $data             = $object['Body']->getContents();
                 $body             = JSON::decode($data);
                 $body             = JSON::decode($data);
                 $items            = explode('/', $entry['Key']);
                 $items            = explode('/', $entry['Key']);
@@ -299,7 +299,7 @@ class S3Storage extends AbstractData
         } catch (S3Exception $e) {
         } catch (S3Exception $e) {
             // no comments found
             // no comments found
         }
         }
-        return $comments;
+        return $this->sortComments($comments);
     }
     }
 
 
     /**
     /**
@@ -328,17 +328,17 @@ class S3Storage extends AbstractData
                 if (strlen($name) > strlen($path) && substr($name, strlen($path), 1) !== '/') {
                 if (strlen($name) > strlen($path) && substr($name, strlen($path), 1) !== '/') {
                     continue;
                     continue;
                 }
                 }
-                $head = $this->_client->headObject(array(
+                $head = $this->_client->headObject([
                     'Bucket' => $this->_bucket,
                     'Bucket' => $this->_bucket,
                     'Key'    => $name,
                     'Key'    => $name,
-                ));
+                ]);
                 $value = $head->get('Metadata')['value'] ?? '';
                 $value = $head->get('Metadata')['value'] ?? '';
                 if (is_numeric($value) && intval($value) < $time) {
                 if (is_numeric($value) && intval($value) < $time) {
                     try {
                     try {
-                        $this->_client->deleteObject(array(
+                        $this->_client->deleteObject([
                             'Bucket' => $this->_bucket,
                             'Bucket' => $this->_bucket,
                             'Key'    => $name,
                             'Key'    => $name,
-                        ));
+                        ]);
                     } catch (S3Exception $e) {
                     } catch (S3Exception $e) {
                         // deleted by another instance.
                         // deleted by another instance.
                     }
                     }
@@ -367,18 +367,18 @@ class S3Storage extends AbstractData
             $key = $prefix . 'config/' . $namespace . '/' . $key;
             $key = $prefix . 'config/' . $namespace . '/' . $key;
         }
         }
 
 
-        $metadata = array('namespace' => $namespace);
+        $metadata = ['namespace' => $namespace];
         if ($namespace !== 'salt') {
         if ($namespace !== 'salt') {
             $metadata['value'] = strval($value);
             $metadata['value'] = strval($value);
         }
         }
         try {
         try {
-            $this->_client->putObject(array(
+            $this->_client->putObject([
                 'Bucket'      => $this->_bucket,
                 'Bucket'      => $this->_bucket,
                 'Key'         => $key,
                 'Key'         => $key,
                 'Body'        => $value,
                 'Body'        => $value,
                 'ContentType' => 'application/json',
                 'ContentType' => 'application/json',
                 'Metadata'    => $metadata,
                 'Metadata'    => $metadata,
-            ));
+            ]);
         } catch (S3Exception $e) {
         } catch (S3Exception $e) {
             error_log('failed to set key ' . $key . ' to ' . $this->_bucket . ', ' .
             error_log('failed to set key ' . $key . ' to ' . $this->_bucket . ', ' .
                 trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
                 trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
@@ -404,10 +404,10 @@ class S3Storage extends AbstractData
         }
         }
 
 
         try {
         try {
-            $object = $this->_client->getObject(array(
+            $object = $this->_client->getObject([
                 'Bucket' => $this->_bucket,
                 'Bucket' => $this->_bucket,
                 'Key'    => $key,
                 'Key'    => $key,
-            ));
+            ]);
             return $object['Body']->getContents();
             return $object['Body']->getContents();
         } catch (S3Exception $e) {
         } catch (S3Exception $e) {
             return '';
             return '';
@@ -419,7 +419,7 @@ class S3Storage extends AbstractData
      */
      */
     protected function _getExpiredPastes($batchsize)
     protected function _getExpiredPastes($batchsize)
     {
     {
-        $expired = array();
+        $expired = [];
         $now     = time();
         $now     = time();
         $prefix  = $this->_prefix;
         $prefix  = $this->_prefix;
         if (!empty($prefix)) {
         if (!empty($prefix)) {
@@ -428,10 +428,10 @@ class S3Storage extends AbstractData
 
 
         try {
         try {
             foreach ($this->_listAllObjects($prefix) as $object) {
             foreach ($this->_listAllObjects($prefix) as $object) {
-                $head = $this->_client->headObject(array(
+                $head = $this->_client->headObject([
                     'Bucket' => $this->_bucket,
                     'Bucket' => $this->_bucket,
                     'Key'    => $object['Key'],
                     'Key'    => $object['Key'],
-                ));
+                ]);
                 $expire_at = $head->get('Metadata')['expire_date'] ?? '';
                 $expire_at = $head->get('Metadata')['expire_date'] ?? '';
                 if (is_numeric($expire_at) && intval($expire_at) < $now) {
                 if (is_numeric($expire_at) && intval($expire_at) < $now) {
                     array_push($expired, $object['Key']);
                     array_push($expired, $object['Key']);
@@ -452,7 +452,7 @@ class S3Storage extends AbstractData
      */
      */
     public function getAllPastes()
     public function getAllPastes()
     {
     {
-        $pastes = array();
+        $pastes = [];
         $prefix = $this->_prefix;
         $prefix = $this->_prefix;
         if (!empty($prefix)) {
         if (!empty($prefix)) {
             $prefix .= '/';
             $prefix .= '/';

+ 0 - 38
lib/Exception/JsonException.php

@@ -1,38 +0,0 @@
-<?php declare(strict_types=1);
-/**
- * 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
- */
-
-namespace PrivateBin\Exception;
-
-use Exception;
-
-/**
- * JsonException
- *
- * An Exception representing JSON en- or decoding errors.
- */
-class JsonException extends Exception
-{
-    /**
-     * Exception constructor with mandatory JSON error code.
-     *
-     * @access public
-     * @param  int $code
-     */
-    public function __construct(int $code)
-    {
-        $message = 'A JSON error occurred';
-        if (function_exists('json_last_error_msg')) {
-            $message .= ': ' . json_last_error_msg();
-        }
-        $message .= ' (' . $code . ')';
-        parent::__construct($message, 90);
-    }
-}

+ 2 - 2
lib/Filter.php

@@ -46,7 +46,7 @@ class Filter
             default:
             default:
                 $unit = rtrim($matches[2], 's');
                 $unit = rtrim($matches[2], 's');
         }
         }
-        return I18n::_(array('%d ' . $unit, '%d ' . $unit . 's'), (int) $matches[1]);
+        return I18n::_(['%d ' . $unit, '%d ' . $unit . 's'], (int) $matches[1]);
     }
     }
 
 
     /**
     /**
@@ -59,7 +59,7 @@ class Filter
      */
      */
     public static function formatHumanReadableSize($size)
     public static function formatHumanReadableSize($size)
     {
     {
-        $iec = array('B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB');
+        $iec = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
         $i   = 0;
         $i   = 0;
         while (($size / 1000) >= 1) {
         while (($size / 1000) >= 1) {
             $size = $size / 1000;
             $size = $size / 1000;

+ 21 - 6
lib/FormatV2.php

@@ -31,7 +31,7 @@ class FormatV2
      */
      */
     public static function isValid(&$message, $isComment = false)
     public static function isValid(&$message, $isComment = false)
     {
     {
-        $required_keys = array('adata', 'v', 'ct');
+        $required_keys = ['adata', 'v', 'ct'];
         if ($isComment) {
         if ($isComment) {
             $required_keys[] = 'pasteid';
             $required_keys[] = 'pasteid';
             $required_keys[] = 'parentid';
             $required_keys[] = 'parentid';
@@ -56,7 +56,21 @@ class FormatV2
             return false;
             return false;
         }
         }
 
 
-        $cipherParams = $isComment ? $message['adata'] : $message['adata'][0];
+        $cipherParams = $isComment ? $message['adata'] : ($message['adata'][0] ?? null);
+
+        // Make sure the cipher parameters are a properly sized array.
+        if (!is_array($cipherParams) || count($cipherParams) < 8) {
+            return false;
+        }
+
+        // Make sure the ciphertext and the cipher parameters used in the
+        // string operations below are actually strings, so that malformed
+        // input yields "Invalid data." instead of a fatal type error.
+        if (!is_string($message['ct']) ||
+            !is_string($cipherParams[0] ?? null) ||
+            !is_string($cipherParams[1] ?? null)) {
+            return false;
+        }
 
 
         // Make sure some fields are base64 data:
         // Make sure some fields are base64 data:
         // - initialization vector
         // - initialization vector
@@ -92,11 +106,11 @@ class FormatV2
             return false;
             return false;
         }
         }
         // - key size
         // - key size
-        if (!in_array($cipherParams[3], array(128, 192, 256), true)) {
+        if (!in_array($cipherParams[3], [128, 192, 256], true)) {
             return false;
             return false;
         }
         }
         // - tag size
         // - tag size
-        if (!in_array($cipherParams[4], array(64, 96, 128), true)) {
+        if (!in_array($cipherParams[4], [64, 96, 128], true)) {
             return false;
             return false;
         }
         }
         // - algorithm, must be AES
         // - algorithm, must be AES
@@ -104,11 +118,11 @@ class FormatV2
             return false;
             return false;
         }
         }
         // - mode
         // - mode
-        if (!in_array($cipherParams[6], array('ctr', 'cbc', 'gcm'), true)) {
+        if (!in_array($cipherParams[6], ['ctr', 'cbc', 'gcm'], true)) {
             return false;
             return false;
         }
         }
         // - compression
         // - compression
-        if (!in_array($cipherParams[7], array('zlib', 'none'), true)) {
+        if (!in_array($cipherParams[7], ['zlib', 'none'], true)) {
             return false;
             return false;
         }
         }
 
 
@@ -119,6 +133,7 @@ class FormatV2
 
 
         // require only the key 'expire' in the metadata of pastes
         // require only the key 'expire' in the metadata of pastes
         if (!$isComment && (
         if (!$isComment && (
+            !is_array($message['meta']) ||
             count($message['meta']) === 0 ||
             count($message['meta']) === 0 ||
             !array_key_exists('expire', $message['meta']) ||
             !array_key_exists('expire', $message['meta']) ||
             count($message['meta']) > 1
             count($message['meta']) > 1

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików