Browse Source

Merge branch 'master' into agent/use-native-popstate-events

rugk 2 tuần trước cách đây
mục cha
commit
cc280feb09
100 tập tin đã thay đổi với 2776 bổ sung và 844 xóa
  1. 3 1
      .github/copilot-instructions.md
  2. 4 4
      .github/dependabot.yml
  3. 1 1
      .github/workflows/test-results.yml
  4. 0 2
      .styleci.yml
  5. 23 3
      CHANGELOG.md
  6. 2 1
      CREDITS.md
  7. 2 2
      Makefile
  8. 1 1
      README.md
  9. 2 2
      SECURITY.md
  10. 8 4
      bin/administration
  11. 1 1
      bin/configuration-test-generator
  12. 19 5
      bin/migrate
  13. 11 0
      cfg/conf.sample.php
  14. 2 2
      composer.json
  15. 38 38
      composer.lock
  16. 83 0
      css/bootstrap5/privatebin.css
  17. 13 1
      doc/Installation.md
  18. 2 1
      i18n/ar.json
  19. 8 7
      i18n/bg.json
  20. 2 1
      i18n/ca.json
  21. 3 2
      i18n/co.json
  22. 2 1
      i18n/cs.json
  23. 2 1
      i18n/de.json
  24. 2 1
      i18n/el.json
  25. 2 1
      i18n/en.json
  26. 2 1
      i18n/es.json
  27. 2 1
      i18n/et.json
  28. 2 1
      i18n/fa.json
  29. 2 1
      i18n/fi.json
  30. 2 1
      i18n/fr.json
  31. 2 1
      i18n/he.json
  32. 2 1
      i18n/hi.json
  33. 2 1
      i18n/hu.json
  34. 2 1
      i18n/id.json
  35. 2 1
      i18n/it.json
  36. 2 1
      i18n/ja.json
  37. 2 1
      i18n/jbo.json
  38. 2 1
      i18n/ko.json
  39. 2 1
      i18n/ku.json
  40. 2 1
      i18n/la.json
  41. 1 0
      i18n/languages.json
  42. 2 1
      i18n/lt.json
  43. 2 1
      i18n/nl.json
  44. 2 1
      i18n/no.json
  45. 2 1
      i18n/oc.json
  46. 3 2
      i18n/pl.json
  47. 2 1
      i18n/pt.json
  48. 2 1
      i18n/ro.json
  49. 2 1
      i18n/ru.json
  50. 16 15
      i18n/sk.json
  51. 16 15
      i18n/sl.json
  52. 2 1
      i18n/sv.json
  53. 45 44
      i18n/th.json
  54. 2 1
      i18n/tr.json
  55. 2 1
      i18n/uk.json
  56. 239 0
      i18n/zh-tw.json
  57. 2 1
      i18n/zh.json
  58. 2 2
      js/common.js
  59. 274 357
      js/package-lock.json
  60. 3 3
      js/package.json
  61. 196 151
      js/privatebin.js
  62. 0 1
      js/purify-3.4.12.js
  63. 4 0
      js/purify-3.4.17.js
  64. 120 35
      js/test/AttachmentViewer.js
  65. 4 2
      js/test/CopyToClipboard.js
  66. 85 16
      js/test/I18n.js
  67. 24 1
      js/test/Model.js
  68. 32 0
      js/test/Prompt.js
  69. 210 0
      js/test/TopNav.js
  70. 115 3
      js/test/emailTemplateTest.js
  71. 6 2
      lib/Configuration.php
  72. 13 4
      lib/Controller.php
  73. 13 0
      lib/Data/AbstractData.php
  74. 1 2
      lib/Data/Database.php
  75. 50 20
      lib/Data/Filesystem.php
  76. 1 1
      lib/Data/GoogleCloudStorage.php
  77. 1 1
      lib/Data/S3Storage.php
  78. 86 20
      lib/I18n.php
  79. 11 3
      lib/Proxy/AbstractProxy.php
  80. 90 0
      lib/Proxy/ChhotoProxy.php
  81. 27 6
      lib/Request.php
  82. 4 1
      lib/TemplateSwitcher.php
  83. 5 5
      tpl/bootstrap5.php
  84. 52 0
      tst/AdministrationEmptyDirsTest.php
  85. 42 0
      tst/AdministrationOptionsTest.php
  86. 22 0
      tst/ControllerTest.php
  87. 29 0
      tst/Data/DatabaseTest.php
  88. 51 1
      tst/Data/FilesystemTest.php
  89. 96 16
      tst/I18nTest.php
  90. 23 5
      tst/JsonApiTest.php
  91. 86 0
      tst/MigrateForceOverwriteTest.php
  92. 31 0
      tst/MigrateTest.php
  93. 115 0
      tst/MigrateWriteFailureTest.php
  94. 175 0
      tst/Proxy/ChhotoProxyTest.php
  95. 0 0
      tst/Proxy/YourlsProxyTest.php
  96. 53 1
      tst/RequestTest.php
  97. 3 0
      tst/TemplateSwitcherTest.php
  98. 11 0
      tst/ViewTest.php
  99. 1 0
      vendor/composer/autoload_classmap.php
  100. 1 0
      vendor/composer/autoload_static.php

+ 3 - 1
.github/copilot-instructions.md

@@ -37,7 +37,8 @@
 ### Build
 
 - **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!)
+  - 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
@@ -117,3 +118,4 @@
 - **Always run `composer install` before PHP tests, and `npm install` before JS tests.**
 - **Validate all changes by running both PHP and JS tests.**
 - **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.

+ 4 - 4
.github/dependabot.yml

@@ -12,7 +12,7 @@ updates:
     directory: "/"
     schedule:
       interval: "daily"
- - package-ecosystem: "npm"
-   directory: "/js"
-   schedule:
-     interval: "daily"
+  - package-ecosystem: "npm"
+    directory: "/js"
+    schedule:
+      interval: "daily"

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

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

+ 0 - 2
.styleci.yml

@@ -3,7 +3,6 @@ preset: recommended
 risky: false
 
 enabled:
-  - align_equals
   - concat_with_spaces
   - no_empty_comment
   - pre_increment
@@ -26,7 +25,6 @@ disabled:
   - phpdoc_summary
   - post_increment
   - single_line_after_imports
-  - unalign_equals
 
 finder:
   path:

+ 23 - 3
CHANGELOG.md

@@ -1,17 +1,37 @@
 # PrivateBin version history
 
-## 2.1.0 (not yet released)
+## 2.1.1 (not yet released)
+* ADDED: Translation for Korean (#2071)
+* FIXED: Creating a document failed with a JavaScript error when the `discussion` or `password` option is disabled, as the top navigation accessed the omitted elements
+* FIXED: Slovenian plural forms were stored in the wrong order, i.e. "1 uri" instead of "1 ura" for one hour (#1951)
+* FIXED: Lithuanian plural formula selected the "few" form for numbers ending in 0 above 10, i.e. "20 sekundės" instead of "20 sekundžių" (#1956)
+
+## 2.1.0 (2026-10-18)
+* 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)
+* ADDED: Send the document with Ctrl+S, or Cmd+S on macOS (#739)
 * 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.
-
-## 2.0.6 (not yet released)
+* 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: DOMpurify 3.4.17, ip-lib 1.22.1 & polyfill-php80 1.43.0
+* 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)
+* FIXED: Sanitize Markdown HTML from CSS classes & styles
+
+## 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)

+ 2 - 1
CREDITS.md

@@ -39,7 +39,7 @@
 ## Translations
 
 * Hexalyse - French
-* Kuba Niewiarowski - Polish
+* Kuba Niewiarowski, Mariusz Krzaczkowski - Polish
 * Gabbalo - German
 * Draky50110 - French
 * Filip Muki Dobranić - Slovene
@@ -72,3 +72,4 @@
 * Edward205 - Romanian
 * babiloof - Swedish
 * 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
 
-CURRENT_VERSION = 2.0.5
-VERSION ?= 2.0.6
+CURRENT_VERSION = 2.1.0
+VERSION ?= 2.1.1
 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_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/)
 
-*Current version: 2.0.5*
+*Current version: 2.1.0*
 
 **PrivateBin** is a minimalist, open source online
 [pastebin](https://en.wikipedia.org/wiki/Pastebin)

+ 2 - 2
SECURITY.md

@@ -4,8 +4,8 @@
 
 | Version | Supported          |
 | ------- | ------------------ |
-| 2.0.5   | :heavy_check_mark: |
-| < 2.0.5 | :x:                |
+| 2.1.0   | :heavy_check_mark: |
+| < 2.1.0 | :x:                |
 
 ## Reporting a Vulnerability
 

+ 8 - 4
bin/administration

@@ -121,7 +121,7 @@ class Administration
             self::_error('instance not using Filesystem storage, no directories to empty', 4);
         }
         $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);
     }
 
@@ -205,7 +205,7 @@ EOT, PHP_EOL;
     private function _option($short, $long)
     {
         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];
             }
         }
@@ -233,9 +233,13 @@ EOT, PHP_EOL;
             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');
             echo PHP_EOL;
             self::_help(3);

+ 1 - 1
bin/configuration-test-generator

@@ -367,7 +367,7 @@ class ConfigurationTestGenerator
                         }
                         $args = array();
                         foreach ($test['args'] as $arg) {
-                            if (is_string($arg) && strpos($arg, '$') === 0) {
+                            if (is_string($arg) && str_starts_with($arg, '$')) {
                                 $args[] = $arg;
                             } else {
                                 $args[] = Helper::varExportMin($arg, true);

+ 19 - 5
bin/migrate

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

+ 11 - 0
cfg/conf.sample.php

@@ -266,6 +266,17 @@ dir = PATH "data"
 ;version = "latest"
 ;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 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,

+ 2 - 2
composer.json

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

+ 38 - 38
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "93df7ad74baea1e462b30852a480707d",
+    "content-hash": "1090fd2525b19170bbc0f0216583a2ca",
     "packages": [
         {
             "name": "jdenticon/jdenticon",
@@ -57,16 +57,16 @@
         },
         {
             "name": "mlocati/ip-lib",
-            "version": "1.22.0",
+            "version": "1.22.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/mlocati/ip-lib.git",
-                "reference": "4e40ffd3bf9989db19403d89c4d8be44b87b8a91"
+                "reference": "f096c0e9ec8bd4691ee45394cfb77c97c6bba272"
             },
             "dist": {
                 "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": ""
             },
             "require": {
@@ -112,7 +112,7 @@
             ],
             "support": {
                 "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": [
                 {
@@ -124,20 +124,20 @@
                     "type": "other"
                 }
             ],
-            "time": "2025-10-15T12:35:09+00:00"
+            "time": "2026-09-17T09:24:44+00:00"
         },
         {
             "name": "symfony/polyfill-php80",
-            "version": "v1.34.0",
+            "version": "v1.43.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php80.git",
-                "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
+                "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
-                "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
+                "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
                 "shasum": ""
             },
             "require": {
@@ -188,7 +188,7 @@
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-php80/tree/v1.34.0"
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0"
             },
             "funding": [
                 {
@@ -208,7 +208,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2026-04-10T16:19:22+00:00"
+            "time": "2026-09-25T15:50:05+00:00"
         },
         {
             "name": "yzalis/identicon",
@@ -396,16 +396,16 @@
         },
         {
             "name": "nikic/php-parser",
-            "version": "v5.8.0",
+            "version": "v5.9.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
+                "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
-                "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def",
+                "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def",
                 "shasum": ""
             },
             "require": {
@@ -447,9 +447,9 @@
             ],
             "support": {
                 "issues": "https://github.com/nikic/PHP-Parser/issues",
-                "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
+                "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0"
             },
-            "time": "2026-07-04T14:30:18+00:00"
+            "time": "2026-09-13T18:51:52+00:00"
         },
         {
             "name": "phar-io/manifest",
@@ -890,16 +890,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "9.6.35",
+            "version": "9.6.37",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b"
+                "reference": "aceaa623912caf8e80bffea22fc4103a0a01565f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b",
-                "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/aceaa623912caf8e80bffea22fc4103a0a01565f",
+                "reference": "aceaa623912caf8e80bffea22fc4103a0a01565f",
                 "shasum": ""
             },
             "require": {
@@ -924,7 +924,7 @@
                 "sebastian/comparator": "^4.0.10",
                 "sebastian/diff": "^4.0.6",
                 "sebastian/environment": "^5.1.5",
-                "sebastian/exporter": "^4.0.8",
+                "sebastian/exporter": "^4.0.9",
                 "sebastian/global-state": "^5.0.8",
                 "sebastian/object-enumerator": "^4.0.4",
                 "sebastian/resource-operations": "^3.0.4",
@@ -973,7 +973,7 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.37"
             },
             "funding": [
                 {
@@ -981,7 +981,7 @@
                     "type": "other"
                 }
             ],
-            "time": "2026-07-06T14:48:07+00:00"
+            "time": "2026-09-23T09:59:59+00:00"
         },
         {
             "name": "sebastian/cli-parser",
@@ -1424,16 +1424,16 @@
         },
         {
             "name": "sebastian/exporter",
-            "version": "4.0.8",
+            "version": "4.0.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/exporter.git",
-                "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
+                "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7"
             },
             "dist": {
                 "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": ""
             },
             "require": {
@@ -1489,7 +1489,7 @@
             ],
             "support": {
                 "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": [
                 {
@@ -1509,7 +1509,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-09-24T06:03:27+00:00"
+            "time": "2026-08-11T04:55:59+00:00"
         },
         {
             "name": "sebastian/global-state",
@@ -1758,16 +1758,16 @@
         },
         {
             "name": "sebastian/recursion-context",
-            "version": "4.0.6",
+            "version": "4.0.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/recursion-context.git",
-                "reference": "539c6691e0623af6dc6f9c20384c120f963465a0"
+                "reference": "c85be6922b7fd365942b986b9a50397d65407611"
             },
             "dist": {
                 "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": ""
             },
             "require": {
@@ -1809,7 +1809,7 @@
             "homepage": "https://github.com/sebastianbergmann/recursion-context",
             "support": {
                 "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": [
                 {
@@ -1829,7 +1829,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-08-10T06:57:39+00:00"
+            "time": "2026-08-11T05:25:24+00:00"
         },
         {
             "name": "sebastian/resource-operations",

+ 83 - 0
css/bootstrap5/privatebin.css

@@ -60,3 +60,86 @@ html[dir="rtl"] #deletelink, html[dir="rtl"] #qrcodemodalClose {
 #sendbutton svg {
 	transform: translateY(1.5px);
 }
+
+/* Markdown rendering */
+
+#plaintext {
+	max-width: 1050px;
+	margin: 2rem auto 5rem;
+	padding: 0 1.5rem;
+	font-size: 16px;
+	line-height: 1.7;
+}
+
+#plaintext h1 {
+	margin: 0 0 1.75rem;
+	padding-bottom: 0.65rem;
+	border-bottom: 2px solid var(--bs-border-color);
+	font-size: 2.25rem;
+}
+
+#plaintext h2 {
+	margin: 2.75rem 0 1.25rem;
+	padding-bottom: 0.4rem;
+	border-bottom: 1px solid var(--bs-border-color);
+	font-size: 1.65rem;
+}
+
+#plaintext h3 {
+	margin: 2rem 0 1rem;
+	font-size: 1.3rem;
+}
+
+#plaintext p {
+	margin-bottom: 1.15rem;
+}
+
+#plaintext pre {
+	margin: 1rem 0 1.5rem;
+	padding: 1rem 1.2rem;
+	overflow-x: auto;
+	background: var(--bs-tertiary-bg);
+	border: 1px solid var(--bs-border-color);
+	border-radius: 0.5rem;
+	box-shadow: inset 3px 0 0 var(--bs-primary);
+	line-height: 1.55;
+}
+
+#plaintext pre code {
+	padding: 0;
+	color: var(--bs-body-color);
+	background: transparent;
+	white-space: pre;
+}
+
+#plaintext :not(pre) > code {
+	padding: 0.15em 0.4em;
+	color: var(--bs-body-color);
+	background: var(--bs-tertiary-bg);
+	border: 1px solid var(--bs-border-color);
+	border-radius: 0.3rem;
+}
+
+#plaintext ul,
+#plaintext ol {
+	margin-bottom: 1.25rem;
+}
+
+#plaintext blockquote {
+	padding: 0.5rem 1rem;
+	color: var(--bs-secondary-color);
+	border-left: 4px solid var(--bs-primary);
+}
+
+#plaintext table {
+	width: 100%;
+	margin: 1.5rem 0;
+}
+
+@media (max-width: 768px) {
+	#plaintext {
+		margin-top: 1rem;
+		padding: 0 1rem;
+		font-size: 15px;
+	}
+}

+ 13 - 1
doc/Installation.md

@@ -19,6 +19,18 @@ for more information.
 
 - [Config Configuration - PrivateBin by @e1mo](https://galaxy.ansible.com/ui/standalone/roles/e1mo/privatebin/) ([Github source code](https://git.sr.ht/~e1mo/ansible-role-privatebin)): Deploy PrivateBin configuration to disk with a customized configuration.
 
+**NOTE:** There are also container images and a Helm chart for Kubernetes:
+
+- [Container images](https://github.com/PrivateBin/docker-nginx-fpm-alpine#image-variants): Pre-installed PrivateBin on Nginx and php-fpm or [FreeUnit](https://github.com/PrivateBin/docker-unit-alpine/#image-variants), both Alpine-based, available on Docker Hub (`privatebin/…`) and GitHub (`ghcr.io/privatebin/…`), with variants for file based, database, Google Cloud and S3 storage.
+
+- [Helm chart](https://github.com/PrivateBin/helm-chart#quick-start): Deploys the container image to Kubernetes:
+
+  ```bash
+  helm repo add privatebin https://privatebin.github.io/helm-chart
+  helm repo update
+  helm install privatebin privatebin/privatebin
+  ```
+
 ### Minimal Requirements
 
 - PHP version 7.4 or above
@@ -206,7 +218,7 @@ CREATE INDEX parent ON prefix_comment(pasteid);
 CREATE TABLE prefix_config (
     id CHAR(16) NOT NULL, value TEXT, PRIMARY KEY (id)
 );
-INSERT INTO prefix_config VALUES('VERSION', '2.0.5');
+INSERT INTO prefix_config VALUES('VERSION', '2.1.0');
 ```
 
 In **PostgreSQL**, the `data`, `attachment`, `nickname` and `vizhash` columns

+ 2 - 1
i18n/ar.json

@@ -234,5 +234,6 @@
     "Theme": "السمة",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 8 - 7
i18n/bg.json

@@ -126,12 +126,12 @@
         "Този документ изтича след %d дни."
     ],
     "This document will expire in %d months.": [
-        "Този документ изтича след една година.",
-        "Този документ изтича след %d години.",
-        "Този документ изтича след %d години.",
-        "Този документ изтича след %d години.",
-        "Този документ изтича след %d години.",
-        "Този документ изтича след %d години."
+        "Този документ изтича след един месец.",
+        "Този документ изтича след %d месеца.",
+        "Този документ изтича след %d месеца.",
+        "Този документ изтича след %d месеца.",
+        "Този документ изтича след %d месеца.",
+        "Този документ изтича след %d месеца."
     ],
     "Please enter the password for this document:": "Моля въведете паролата за това съдържание:",
     "Could not decrypt data (Wrong key?)": "Информацията не можеше да се дешифрова (Грешен ключ?)",
@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/ca.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 3 - 2
i18n/co.json

@@ -170,7 +170,7 @@
     "Markdown": "Markdown",
     "Download attachment": "Scaricà a pezza ghjunta",
     "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",
     "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.",
@@ -234,5 +234,6 @@
     "Theme": "Tema",
     "Copy": "Cupià",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Appughjà nant’à %s+s per creà u ducumentu è piazzallu nant’à %s"
 }

+ 2 - 1
i18n/cs.json

@@ -234,5 +234,6 @@
     "Theme": "Vzhled",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/de.json

@@ -234,5 +234,6 @@
     "Theme": "Design",
     "Copy": "Kopieren",
     "Cmd": "Cmd",
-    "Ctrl": "Strg"
+    "Ctrl": "Strg",
+    "Hit %s+s to create the document, storing it on %s": "Drücke %s+s um das Dokument zu erstellen und es auf %s zu speichern"
 }

+ 2 - 1
i18n/el.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/en.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/es.json

@@ -234,5 +234,6 @@
     "Theme": "Tema",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/et.json

@@ -234,5 +234,6 @@
     "Theme": "Teema",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/fa.json

@@ -234,5 +234,6 @@
     "Theme": "تم",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/fi.json

@@ -234,5 +234,6 @@
     "Theme": "Teema",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/fr.json

@@ -234,5 +234,6 @@
     "Theme": "Thème",
     "Copy": "Copier",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Appuyer sur %s+s pour créer le document, le stocker sur %s"
 }

+ 2 - 1
i18n/he.json

@@ -234,5 +234,6 @@
     "Theme": "נושא",
     "Copy": "העתק",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/hi.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/hu.json

@@ -234,5 +234,6 @@
     "Theme": "Téma",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/id.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/it.json

@@ -234,5 +234,6 @@
     "Theme": "Tema",
     "Copy": "Copiare",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Premi %s+s per creare il documento, salvandolo su %s"
 }

+ 2 - 1
i18n/ja.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/jbo.json

@@ -234,5 +234,6 @@
     "Theme": "jvinu",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/ko.json

@@ -234,5 +234,6 @@
     "Theme": "테마",
     "Copy": "복사",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "%s+s를 눌러 문서를 생성하고 %s에 저장하세요."
 }

+ 2 - 1
i18n/ku.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/la.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 1 - 0
i18n/languages.json

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

+ 2 - 1
i18n/lt.json

@@ -234,5 +234,6 @@
     "Theme": "Apipavidalinimas",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/nl.json

@@ -234,5 +234,6 @@
     "Theme": "Thema",
     "Copy": "Kopieëren",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/no.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/oc.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 3 - 2
i18n/pl.json

@@ -232,7 +232,8 @@
     "Show password": "Pokaż hasło",
     "Hide password": "Ukryj hasło",
     "Theme": "Motyw",
-    "Copy": "Copy",
+    "Copy": "Kopiuj",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Naciśnij %s+s, aby utworzyć dokument i zapisać go w %s"
 }

+ 2 - 1
i18n/pt.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/ro.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/ru.json

@@ -234,5 +234,6 @@
     "Theme": "Тема",
     "Copy": "Скопировать",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 16 - 15
i18n/sk.json

@@ -210,29 +210,30 @@
     "Encrypted note on %s": "Zašifrovaná poznámka na %s",
     "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Ak chcete zobraziť poznámku, navštívte tento odkaz. Poskytnutie adresy URL komukoľvek im umožní prístup aj k poznámke.",
     "URL shortener may expose your decrypt key in URL.": "Skracovač adries URL môže odhaliť váš dešifrovací kľúč v adrese URL.",
-    "URL shortener is enabled by default.": "URL shortener is enabled by default.",
+    "URL shortener is enabled by default.": "Skracovač adries URL je predvolene povolený.",
     "Save document": "Uložiť príspevok",
     "Your IP is not authorized to create documents.": "Vaša IP adresa nie je oprávnená vytvárať príspevky.",
     "Trying to shorten a URL that isn't pointing at our instance.": "Pokúšate sa skrátiť adresu URL, ktorá neukazuje na túto inštanciu.",
-    "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?",
+    "Proxy error: Proxy URL is empty. This can be a configuration issue, like wrong or missing config keys.": "Chyba proxy: Adresa URL proxy je prázdna. Môže ísť o problém s konfiguráciou, napríklad nesprávne alebo chýbajúce konfiguračné kľúče.",
+    "Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.": "Chyba servera proxy: Chyba pri analýze odpovede servera proxy. Môže ísť o problém s konfiguráciou, napríklad nesprávne alebo chýbajúce konfiguračné kľúče.",
+    "Proxy error: Bad response. This can be a configuration issue, like wrong or missing config keys or a temporary outage.": "Chyba servera proxy: zlá odpoveď. Môže to byť problém s konfiguráciou, napríklad nesprávne alebo chýbajúce konfiguračné kľúče alebo dočasný výpadok.",
+    "This secret message can only be displayed once. Would you like to see it now?": "Táto tajná správa môže byť zobrazená iba raz. Chceli by ste to teraz vidieť?",
     "Yes, see it": "Áno, zobraziť",
     "Dark Mode": "Tmavý Režim",
-    "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.",
+    "Error compressing document, due to missing WebAssembly support.": "Chyba pri komprimácii dokumentu z dôvodu chýbajúcej podpory WebAssembly.",
+    "Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.": "Chyba pri dekomprimácii dokumentu, váš prehliadač nepodporuje WebAssembly. Na zobrazenie tohto dokumentu použite iný prehliadač.",
     "Start over": "Začať odznova",
     "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>%s</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>": "Ak chcete skopírovať dokument, stlačte tlačidlo kopírovania alebo použite skratku zo schránky <kbd>%s</kbd>+<kbd>c</kbd>",
     "Copy link": "Kopírovať odkaz",
     "Link copied to clipboard": "Odkaz vložený do schránky",
-    "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",
+    "Document text": "Text dokumentu",
+    "Tabulator key serves as character (Hit <kbd>Ctrl</kbd>+<kbd>m</kbd> or <kbd>Esc</kbd> to toggle)": "Kláves tabulátora slúži ako znak (pre prepnutie stlačte <kbd>Ctrl</kbd>+<kbd>m</kbd> alebo <kbd>Esc</kbd>)",
+    "Show password": "Zobraziť heslo",
+    "Hide password": "Skryť heslo",
+    "Theme": "Téma",
+    "Copy": "Kopírovať",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 16 - 15
i18n/sl.json

@@ -7,11 +7,11 @@
     "%s requires php %s or above to work. Sorry.": "Oprosti, %s za delovanje potrebuje vsaj php %s.",
     "%s requires configuration section [%s] to be present in configuration file.": "%s potrebuje sekcijo konfiguracij [%s] v konfiguracijski datoteki.",
     "Please wait %d seconds between each post.": [
+        "Prosim počakaj vsaj %d sekund pred vsako naslednjo objavo.",
         "Prosim počakaj vsaj %d sekundo pred vsako naslednjo objavo.",
         "Prosim počakaj vsaj %d sekundi pred vsako naslednjo objavo.",
         "Prosim počakaj vsaj %d sekunde pred vsako naslednjo objavo.",
         "Prosim počakaj vsaj %d sekund pred vsako naslednjo objavo.",
-        "Prosim počakaj vsaj %d sekund pred vsako naslednjo objavo.",
         "Prosim počakaj vsaj %d sekund pred vsako naslednjo objavo."
     ],
     "Document is limited to %s of encrypted data.": "Velikost prilepka je omejena na %s zakodiranih podatkov.",
@@ -36,101 +36,101 @@
     "Discussion": "Razprava",
     "Toggle navigation": "Preklopi navigacijo",
     "%d seconds": [
+        "%d sekund",
         "%d sekunda",
         "%d sekundi",
         "%d sekunde",
         "%d sekund",
-        "%d sekund",
         "%d sekund"
     ],
     "%d minutes": [
+        "%d minut",
         "%d minuta",
         "%d minuti",
         "%d minute",
         "%d minut",
-        "%d minut",
         "%d minut"
     ],
     "%d hours": [
+        "%d ur",
         "%d ura",
         "%d uri",
         "%d ure",
         "%d ur",
-        "%d ur",
         "%d ur"
     ],
     "%d days": [
+        "%d dni",
         "%d dan",
         "%d dneva",
         "%d dnevi",
         "%d dni",
-        "%d dni",
         "%d dni"
     ],
     "%d weeks": [
+        "%d tednov",
         "%d teden",
         "%d tedna",
         "%d tedni",
         "%d tednov",
-        "%d tednov",
         "%d tednov"
     ],
     "%d months": [
+        "%d mesecev",
         "%d mesec",
         "%d meseca",
         "%d meseci",
         "%d mesecev",
-        "%d mesecev",
         "%d mesecev"
     ],
     "%d years": [
+        "%d let",
         "%d leto",
         "%d leti",
         "%d leta",
         "%d let",
-        "%d let",
         "%d let"
     ],
     "Never": "Nikoli",
     "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "Ne pozabi: To je testna storitev: Podatki so lahko kadarkoli pobrisani. Mucki bodo umrli, če boš zlorabljala to storitev.",
     "This document will expire in %d seconds.": [
+        "Ta dokument bo potekel čez %d sekund.",
         "Ta dokument bo potekel čez %d sekundo.",
         "Ta dokument bo potekel čez %d sekundi.",
         "Ta dokument bo potekel čez %d sekunde.",
         "Ta dokument bo potekel čez %d sekund.",
-        "Ta dokument bo potekel čez %d sekund.",
         "Ta dokument bo potekel čez %d sekund."
     ],
     "This document will expire in %d minutes.": [
+        "Ta dokument bo potekel čez %d minut.",
         "Ta dokument bo potekel čez %d minuto.",
         "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 minut."
     ],
     "This document will expire in %d hours.": [
+        "Ta dokument bo potekel čez %d ur.",
         "Ta dokument bo potekel čez %d uro.",
         "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 ur."
     ],
     "This document will expire in %d days.": [
+        "Ta dokument bo potekel čez %d dni.",
         "Ta dokument bo potekel čez %d dan.",
         "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."
     ],
     "This document will expire in %d months.": [
+        "Ta dokument bo potekel čez %d mesecev.",
         "Ta dokument bo potekel čez %d mesec.",
         "Ta dokument bo potekel čez %d meseca.",
         "Ta dokument bo potekel čez %d mesece.",
         "Ta dokument bo potekel čez %d mesecev.",
-        "Ta dokument bo potekel čez %d mesecev.",
         "Ta dokument bo potekel čez %d mesecev."
     ],
     "Please enter the password for this document:": "Prosim vnesi geslo tega prilepka:",
@@ -232,7 +232,8 @@
     "Show password": "Pokaži geslo",
     "Hide password": "Skrij geslo",
     "Theme": "Tema",
-    "Copy": "Copy",
+    "Copy": "Kopiraj",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/sv.json

@@ -234,5 +234,6 @@
     "Theme": "Tema",
     "Copy": "Kopiera",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 45 - 44
i18n/th.json

@@ -1,9 +1,9 @@
 {
     "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>",
     "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 configuration section [%s] to be present in configuration file.": "%s จำเป็นต้องตั้งค่าตัวแปร [%s] ในไฟล์กำหนดค่า",
     "Please wait %d seconds between each post.": [
@@ -14,19 +14,19 @@
         "กรุณาเว้นระยะเวลาการส่งข้อมูลอย่างน้อย %d วินาที",
         "กรุณาเว้นระยะเวลาการส่งข้อมูลอย่างน้อย %d วินาที"
     ],
-    "Document is limited to %s of encrypted data.": "การฝากโค้ดแบบเข้ารหัส ขีดจำกัดสูงสุดคือ %s",
+    "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.": "ข้อมูลการฝากโค้ดถูกลบออกเรียบร้อยแล้ว",
+    "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 สามารถทำงานได้ ขออภัยในความไม่สะดวก",
     "%s requires a modern browser to work.": "%s ต้องใช้เบราว์เซอร์สมัยใหม่ถึงจะสามารถใช้งานได้",
     "New": "ใหม่",
-    "Create": "สร้าง",
+    "Create": "สร้าง ",
     "Clone": "โคลน",
     "Raw text": "ข้อความล้วน",
     "Expires": "หมดอายุ",
@@ -133,9 +133,9 @@
         "เอกสารนี้จะหมดอายุใน %d เดือน",
         "เอกสารนี้จะหมดอายุใน %d เดือน"
     ],
-    "Please enter the password for this document:": "กรุณากรอกรหัสผ่านเพื่อเปิดข้อมูลการฝากโค้ดนี้:",
+    "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.": "ไม่สามารถลบการฝากโค้ดนี้ได้ เนื่องจากว่าไม่ได้ถูกเก็บไว้ในโหมดลบทันทีเมื่อเปิดอ่าน",
+    "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": "ตอบกลับ",
@@ -150,13 +150,13 @@
     "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>",
+    "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 ออกหรือไม่)",
+    "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",
-    "kB": "kB",
+    "kB": "kB ",
     "MB": "MB",
     "GB": "GB",
     "TB": "TB",
@@ -170,9 +170,9 @@
     "Markdown": "Markdown",
     "Download attachment": "ดาวน์โหลดไฟล์แนบ",
     "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": "แนบไฟล์",
-    "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.": "ไฟล์มีขนาดใหญ่เกินไปที่จะแสดงตัวอย่าง กรุณาดาวน์โหลดเป็นไฟล์แนบแทน",
     "Remove attachment": "ลบไฟล์แนบ",
     "Your browser does not support uploading encrypted files. Please use a newer browser.": "เบราว์เซอร์ของคุณไม่สนับสนุนการอัปโหลดไฟล์แบบเข้ารหัสได้ กรุณาใช้เบราว์เซอร์ที่ใหม่กว่า",
@@ -185,11 +185,11 @@
     "Decrypt": "ถอดรหัส",
     "Enter password": "กรอกรหัสผ่าน",
     "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>",
-    "+++ no document text +++": "+++ ไม่มีข้อความการฝากโค้ด +++",
-    "Could not get document data: %s": "ไม่สามารถดึงข้อมูลการฝากโค้ดได้: %s",
+    "+++ no document text +++": "+++ ไม่มีข้อความในเอกสาร +++",
+    "Could not get document data: %s": "ไม่สามารถดึงข้อมูลเอกสารได้: %s",
     "QR code": "คิวอาร์โค้ด",
     "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>",
@@ -210,29 +210,30 @@
     "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.": "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.": "กำลังพยายามใช้เครื่องมือสร้างลิงก์ย่อ ที่ไม่ได้ชี้ไปที่อินสแตนซ์ของเรา",
-    "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": "โหมดสีเข้ม",
-    "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": "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"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/tr.json

@@ -234,5 +234,6 @@
     "Theme": "Theme",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 1
i18n/uk.json

@@ -234,5 +234,6 @@
     "Theme": "Тема",
     "Copy": "Copy",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 239 - 0
i18n/zh-tw.json

@@ -0,0 +1,239 @@
+{
+    "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",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
+}

+ 2 - 1
i18n/zh.json

@@ -234,5 +234,6 @@
     "Theme": "主题",
     "Copy": "复制",
     "Cmd": "Cmd",
-    "Ctrl": "Ctrl"
+    "Ctrl": "Ctrl",
+    "Hit %s+s to create the document, storing it on %s": "Hit %s+s to create the document, storing it on %s"
 }

+ 2 - 2
js/common.js

@@ -31,7 +31,7 @@ require('./prettify');
 global.prettyPrint = window.PR ? window.PR.prettyPrint : function() {};
 global.prettyPrintOne = window.PR ? window.PR.prettyPrintOne : function() {};
 global.showdown = require('./showdown-2.1.0');
-global.DOMPurify = require('./purify-3.4.12');
+global.DOMPurify = require('./purify-3.4.17');
 global.baseX = require('./base-x-5.0.1').baseX;
 global.Legacy = require('./legacy').Legacy;
 require('./privatebin');
@@ -51,7 +51,7 @@ var a2zString    = ['a','b','c','d','e','f','g','h','i','j','k','l','m',
         })
     ),
     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'],
+    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'];
 

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 274 - 357
js/package-lock.json


+ 3 - 3
js/package.json

@@ -1,6 +1,6 @@
 {
   "name": "privatebin",
-  "version": "2.0.5",
+  "version": "2.1.0",
   "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",
   "directories": {
@@ -9,13 +9,13 @@
   "devDependencies": {
     "@peculiar/webcrypto": "^1.5.0",
     "eslint": "^9.37.0",
-    "fast-check": "^4.7.0",
+    "fast-check": "^4.10.2",
     "jsdom": "^26.1.0",
     "jsdom-global": "^3.0.2",
     "jshint": "^2.13.6",
     "jsverify": "^0.8.3",
     "mime-types": "^3.0.2",
-    "mocha": "^11.7.5",
+    "mocha": "^12.0.0",
     "nyc": "^18.0.0"
   },
   "scripts": {

+ 196 - 151
js/privatebin.js

@@ -56,7 +56,7 @@ window.PrivateBin = (function () {
      */
     const purifyHtmlConfig = {
         ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
-        ALLOWED_ATTR: ['href', 'id', 'target', 'rel', 'class', 'style'],
+        ALLOWED_ATTR: ['href'],
         USE_PROFILES: {
             html: true
         }
@@ -72,9 +72,8 @@ window.PrivateBin = (function () {
      */
     const purifyHtmlConfigStrictSubset = {
         ALLOWED_URI_REGEXP: purifyHtmlConfig.ALLOWED_URI_REGEXP,
-        ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
-        ALLOWED_ATTR: ['href', 'id', 'target', 'rel'],
-        ADD_ATTR: ['target', 'rel']
+        ALLOWED_TAGS: ['a'],
+        ALLOWED_ATTR: ['href']
     };
 
     /**
@@ -89,6 +88,20 @@ window.PrivateBin = (function () {
         }
     };
 
+    /**
+     * DOMpurify settings for translated messages, which are allowed to include limited HTML.
+     *
+     * NOTE: The key {@link purifyHtmlConfig.USE_PROFILES} **must not** be included,
+     * as otherwise `USE_PROFILES` takes precedence over {@link purifyHtmlConfigStrictSubset.ALLOWED_TAGS}.
+     *
+     * @private
+     */
+    const purifyTranslationConfig = {
+        ALLOWED_URI_REGEXP: /^(?:https?:)/i, // the only URL expected here is the generated document, so only http & https
+        ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
+        ALLOWED_ATTR: ['href', 'id'] // one of the messages contains an id attribute
+    };
+
     /**
      * URL fragment prefix requiring load confirmation
      *
@@ -492,11 +505,9 @@ window.PrivateBin = (function () {
             const name = cname + '=',
                 ca = document.cookie.split(';');
             for (let i = 0; i < ca.length; ++i) {
-                let c = ca[i];
-                while (c.charAt(0) === ' ') {
-                    c = c.substring(1);
-                }
-                if (c.indexOf(name) === 0) {
+                let c = ca[i].trim();
+                if (c.startsWith(name))
+                {
                     return c.substring(name.length, c.length);
                 }
             }
@@ -667,7 +678,7 @@ window.PrivateBin = (function () {
          * @prop   {string[]}
          * @readonly
          */
-        const supportedLanguages = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fa', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ro', 'ru', 'sk', 'sl', 'sv', 'th', 'tr', 'uk', 'zh'];
+        const supportedLanguages = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fa', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'ko', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ro', 'ru', 'sk', 'sl', 'sv', 'th', 'tr', 'uk', 'zh', 'zh-tw'];
 
         /**
          * built in language
@@ -807,7 +818,7 @@ window.PrivateBin = (function () {
 
             if (containsHtml) {
                 // only allow tags/attributes we actually use in translations
-                output = DOMPurify.sanitize(output, purifyHtmlConfigStrictSubset);
+                output = DOMPurify.sanitize(output, purifyTranslationConfig);
             }
 
             // if element is given, insert translation
@@ -857,16 +868,18 @@ window.PrivateBin = (function () {
                 case 'oc':
                 case 'tr':
                 case 'zh':
+                case 'zh-tw':
                     return n > 1 ? 1 : 0;
                 case 'he':
                     return n === 1 ? 0 : (n === 2 ? 1 : ((n < 0 || n > 10) && (n % 10 === 0) ? 2 : 3));
                 case 'id':
                 case 'ja':
                 case 'jbo':
+                case 'ko':
                 case 'th':
                     return 0;
                 case 'lt':
-                    return n % 10 === 1 && n % 100 !== 11 ? 0 : ((n % 10 >= 2 && n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
+                    return n % 10 === 1 && n % 100 !== 11 ? 0 : ((n % 10 >= 2 && (n % 100 < 10 || n % 100 >= 20)) ? 1 : 2);
                 case 'pl':
                     return n === 1 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
                 case 'ro':
@@ -893,8 +906,16 @@ window.PrivateBin = (function () {
 
             // auto-select language based on browser settings
             if (newLanguage.length === 0) {
-                newLanguage = (navigator.language || navigator.userLanguage || 'en');
-                if (newLanguage.indexOf('-') > 0) {
+                newLanguage = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
+                const isTraditionalChinese = newLanguage.startsWith('zh-tw-') ||
+                    newLanguage === 'zh-hant' || newLanguage.startsWith('zh-hant-') ||
+                    newLanguage === 'zh-hk' || newLanguage.startsWith('zh-hk-') ||
+                    newLanguage === 'zh-mo' || newLanguage.startsWith('zh-mo-');
+                // alias Traditional Chinese browser tags to PrivateBin's zh-tw locale;
+                // otherwise try the full tag and fall back to the base language
+                if (isTraditionalChinese && supportedLanguages.includes('zh-tw')) {
+                    newLanguage = 'zh-tw';
+                } else if (!supportedLanguages.includes(newLanguage) && newLanguage.includes('-')) {
                     newLanguage = newLanguage.split('-')[0];
                 }
             }
@@ -911,7 +932,7 @@ window.PrivateBin = (function () {
             }
 
             // if language is not supported, show error
-            if (supportedLanguages.indexOf(newLanguage) === -1) {
+            if (!supportedLanguages.includes(newLanguage)) {
                 console.error('Language \'%s\' is not supported. Translation failed, fallback to English.', newLanguage);
                 language = 'en';
                 return;
@@ -938,6 +959,27 @@ window.PrivateBin = (function () {
                 });
         };
 
+        /**
+         * Register a callback to be invoked when translations have been loaded.
+         *
+         * This is useful for code that needs to re-apply translated strings to
+         * DOM attributes (e.g. title attributes) that cannot be handled by
+         * I18n.translate's built-in element re-translation mechanism (which only
+         * sets textContent/innerHTML).
+         *
+         * Note: If translations are already loaded when this is called, the
+         * callback will not be invoked. Callers should apply the translation
+         * immediately as well, to cover the case where the language is already
+         * available.
+         *
+         * @name   I18n.onLanguageLoaded
+         * @function
+         * @param     {function} callback - function to call when language is loaded
+         */
+        me.onLanguageLoaded = function (callback) {
+            document.addEventListener(languageLoadedEvent, callback);
+        };
+
         /**
          * resets state, used for unit testing
          *
@@ -963,7 +1005,7 @@ window.PrivateBin = (function () {
         function isStringContainsHtml(messageId) {
             // message IDs are allowed to contain anchors, spans, keyboard and emphasis tags
             // we can recognize all of them by only checking for anchors and keyboard tags
-            return typeof messageId === 'string' && (messageId.indexOf('<a') !== -1 || messageId.indexOf('<kbd') !== -1);
+            return typeof messageId === 'string' && (messageId.includes('<a') || messageId.includes('<kbd'));
         }
 
         return me;
@@ -985,49 +1027,6 @@ window.PrivateBin = (function () {
          */
         const base58 = new baseX('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz');
 
-        /**
-         * convert UTF-8 string stored in a DOMString to a standard UTF-16 DOMString
-         *
-         * Iterates over the bytes of the message, converting them all hexadecimal
-         * percent encoded representations, then URI decodes them all
-         *
-         * @name   CryptTool.utf8To16
-         * @function
-         * @private
-         * @param  {string} message UTF-8 string
-         * @return {string} UTF-16 string
-         */
-        function utf8To16(message) {
-            return decodeURIComponent(
-                message.split('').map(
-                    function (character) {
-                        return '%' + ('00' + character.charCodeAt(0).toString(16)).slice(-2);
-                    }
-                ).join('')
-            );
-        }
-
-        /**
-         * convert DOMString (UTF-16) to a UTF-8 string stored in a DOMString
-         *
-         * URI encodes the message, then finds the percent encoded characters
-         * and transforms these hexadecimal representation back into bytes
-         *
-         * @name   CryptTool.utf16To8
-         * @function
-         * @private
-         * @param  {string} message UTF-16 string
-         * @return {string} UTF-8 string
-         */
-        function utf16To8(message) {
-            return encodeURIComponent(message).replace(
-                /%([0-9A-F]{2})/g,
-                function (match, hexCharacter) {
-                    return String.fromCharCode('0x' + hexCharacter);
-                }
-            );
-        }
-
         /**
          * convert ArrayBuffer into a UTF-8 string
          *
@@ -1039,14 +1038,11 @@ window.PrivateBin = (function () {
          * @param  {ArrayBuffer} messageArray
          * @return {string} message
          */
-        function arraybufferToString(messageArray) {
-            const array = new Uint8Array(messageArray);
-            let message = '',
-                i = 0;
-            while (i < array.length) {
-                message += String.fromCharCode(array[i++]);
-            }
-            return message;
+        function arraybufferToString(messageArray)
+        {
+            return Array.from(new Uint8Array(messageArray))
+                .map(byte => String.fromCharCode(byte))
+                .join('');
         }
 
         /**
@@ -1060,12 +1056,12 @@ window.PrivateBin = (function () {
          * @param  {string} message UTF-8 string
          * @return {Uint8Array} array
          */
-        function stringToArraybuffer(message) {
-            const messageArray = new Uint8Array(message.length);
-            for (let i = 0; i < message.length; ++i) {
-                messageArray[i] = message.charCodeAt(i);
-            }
-            return messageArray;
+        function stringToArraybuffer(message)
+        {
+            return Uint8Array.from(
+                message,
+                character => character.charCodeAt(0)
+            );
         }
 
         /**
@@ -1082,9 +1078,7 @@ window.PrivateBin = (function () {
          * @return {ArrayBuffer} data
          */
         async function compress(message, mode, zlib) {
-            message = stringToArraybuffer(
-                utf16To8(message)
-            );
+            message = new TextEncoder().encode(message);
             if (mode === 'zlib') {
                 if (typeof zlib === 'undefined') {
                     throw new Error('Error compressing document, due to missing WebAssembly support.');
@@ -1116,9 +1110,7 @@ window.PrivateBin = (function () {
                     new Uint8Array(data)
                 ).buffer;
             }
-            return utf8To16(
-                arraybufferToString(data)
-            );
+            return new TextDecoder('utf-8', {fatal: true}).decode(data);
         }
 
         /**
@@ -1453,7 +1445,7 @@ window.PrivateBin = (function () {
          * @throws {string}
          */
         me.getPasteId = function () {
-            const idRegEx = /^[a-z0-9]{16}$/;
+            const idRegEx = /^[a-f0-9]{16}$/;
 
             // return cached value
             if (id !== null) {
@@ -1489,7 +1481,7 @@ window.PrivateBin = (function () {
          * @return {bool}
          */
         me.hasDeleteToken = function () {
-            return window.location.search.indexOf('deletetoken') !== -1;
+            return (new URL(window.location)).searchParams.has('deletetoken');
         };
 
         /**
@@ -2840,10 +2832,10 @@ window.PrivateBin = (function () {
          */
         function getBlobUrl(data, mimeType) {
             // Transform into a Blob
-            const buf = new Uint8Array(data.length);
-            for (let i = 0; i < data.length; ++i) {
-                buf[i] = data.charCodeAt(i);
-            }
+            const buf = Uint8Array.from(
+                data,
+                character => character.charCodeAt(0)
+            );
             const blob = new window.Blob(
                 [buf],
                 {
@@ -2879,22 +2871,22 @@ window.PrivateBin = (function () {
 
             // We explicitly do _not_ use the original mime type for the download link
             // to always force a download instead of potentially dangerous browser rendering/parsing/interpretation
-            let safeMimeType = 'application/octet-stream';
+            let sanitizedMimeType = 'application/octet-stream';
             if (me.isSafeMimeType(mimeType)) {
-                safeMimeType = mimeType;
+                sanitizedMimeType = mimeType;
             }
 
             // extract data and convert to binary
-            const rawData = attachmentData.substring(base64Start);
-            const decodedData = rawData.length > 0 ? atob(rawData) : '';
+            const base64Data = attachmentData.substring(base64Start);
+            const plainData = base64Data.length > 0 ? atob(base64Data) : '';
 
-            let blobUrl = getBlobUrl(decodedData, safeMimeType);
+            let blobUrl = getBlobUrl(plainData, sanitizedMimeType);
             attachmentLink.setAttribute('href', blobUrl);
 
             if (typeof fileName !== 'undefined') {
                 attachmentLink.setAttribute('download', fileName);
 
-                const fileSize = Helper.formatBytes(decodedData.length);
+                const fileSize = Helper.formatBytes(plainData.length);
                 const spans = template.querySelectorAll('span');
                 const span = spans[spans.length - 1];
                 span.textContent += ` (${fileName}, ${fileSize})`;
@@ -2904,18 +2896,36 @@ window.PrivateBin = (function () {
             // prevents executing embedded scripts when CSP is not set and user
             // right-clicks/long-taps and opens the SVG in a new tab - prevented
             // in the preview by use of an img tag, which disables scripts, too
-            if (mimeType.match(/^image\/.*svg/i)) {
-                const sanitizedData = DOMPurify.sanitize(
-                    decodedData,
-                    purifySvgConfig
-                );
-                blobUrl = getBlobUrl(sanitizedData, mimeType);
+            if (mimeType.startsWith('image/svg')) {
+                try {
+                    // attempt to UTF-8 decode the SVG data
+                    const svgBuffer = Uint8Array.from(
+                        plainData,
+                        character => character.charCodeAt(0)
+                    );
+                    const utf8ValidatedSvgString = new TextDecoder(
+                        'utf-8',
+                        {fatal: true}
+                    ).decode(svgBuffer);
+                    const sanitizedData = DOMPurify.sanitize(
+                        utf8ValidatedSvgString,
+                        purifySvgConfig
+                    );
+                    sanitizedMimeType = 'image/svg+xml';
+                    blobUrl = getBlobUrl(sanitizedData, sanitizedMimeType);
+                } catch (e) {
+                    // Invalid or non-UTF-8 SVG: download only, no preview as it
+                    // may be used to smuggle multi-byte sequences past DOMpurify
+                    // such as `&#x13c` to get `\x01<` & `&#x13e` to get `\x01>`
+                    sanitizedMimeType = 'application/octet-stream';
+                    blobUrl = getBlobUrl(plainData, sanitizedMimeType);
+                }
             }
 
             template.classList.remove('hidden');
             attachment.appendChild(template);
 
-            me.handleBlobAttachmentPreview(attachmentPreview, blobUrl, mimeType);
+            me.handleBlobAttachmentPreview(attachmentPreview, blobUrl, sanitizedMimeType);
         };
 
 
@@ -2931,35 +2941,16 @@ window.PrivateBin = (function () {
          * @returns {bool}
          */
         me.isSafeMimeType = function(mimeType) {
-            return (
-                    mimeType.startsWith('image/') &&
-                    !mimeType.includes('svg')
-                ) ||
-                mimeType.startsWith('video/') ||
-                mimeType.startsWith('audio/') ||
-                mimeType.endsWith('/pdf') ||
-                mimeType === 'text/plain';
-        };
-
-        /**
-         * Evaluates whether this is known a safe mime type.
-         *
-         * This means, the media can safely be displayed and e.g. no XSS should be possible.
-         *
-         * @name AttachmentViewer.isSafeMimeType
-         * @function
-         * @param {string}
-         * @returns {bool}
-         */
-        me.isSafeMimeType = function(mimeType) {
-            return (
+            return ((
                     mimeType.startsWith('image/') &&
-                    !mimeType.includes('svg')
+                    !/svg/i.test(mimeType)
                 ) ||
                 mimeType.startsWith('video/') ||
                 mimeType.startsWith('audio/') ||
-                mimeType.endsWith('/pdf') ||
-                mimeType === 'text/plain';
+                mimeType === 'application/pdf' ||
+                mimeType === 'text/plain') &&
+                // don't accept comments, stray characters, spaces, etc.
+                /^[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-z0-9][a-z0-9.+-]*[a-z0-9]$/.test(mimeType);
         };
 
         /**
@@ -3144,9 +3135,12 @@ window.PrivateBin = (function () {
         me.getAttachmentMimeType = function (attachmentData) {
             // position in data URI string of where mimeType ends
             const mimeTypeEnd = attachmentData.indexOf(';');
+            if (mimeTypeEnd < 6) {
+                return '';
+            }
 
             // extract mimeType
-            return attachmentData.substring(5, mimeTypeEnd);
+            return attachmentData.substring(5, mimeTypeEnd).toLowerCase();
         };
 
         /**
@@ -3249,12 +3243,12 @@ window.PrivateBin = (function () {
             const alreadyIncludesCurrentAttachment = targetElement.querySelectorAll(`[src='${blobUrl}']`).length > 0;
 
             if (blobUrl && !alreadyIncludesCurrentAttachment) {
-                if (mimeType.toLowerCase().startsWith('image/')) {
+                if (mimeType.startsWith('image/')) {
                     const image = document.createElement('img');
                     image.setAttribute('src', blobUrl);
                     image.setAttribute('class', 'img-thumbnail');
                     targetElement.appendChild(image);
-                } else if (mimeType.toLowerCase().startsWith('video/')) {
+                } else if (mimeType.startsWith('video/')) {
                     const video = document.createElement('video');
                     video.setAttribute('controls', 'true');
                     video.setAttribute('autoplay', 'true');
@@ -3264,7 +3258,7 @@ window.PrivateBin = (function () {
                     source.setAttribute('src', blobUrl);
                     video.appendChild(source);
                     targetElement.appendChild(video);
-                } else if (mimeType.toLowerCase().startsWith('audio/')) {
+                } else if (mimeType.startsWith('audio/')) {
                     const audio = document.createElement('audio');
                     audio.setAttribute('controls', 'true');
                     audio.setAttribute('autoplay', 'true');
@@ -3273,7 +3267,7 @@ window.PrivateBin = (function () {
                     source.setAttribute('src', blobUrl);
                     audio.appendChild(source);
                     targetElement.appendChild(audio);
-                } else if (mimeType.toLowerCase().endsWith('/pdf')) {
+                } else if (mimeType === 'application/pdf') {
                     const embed = document.createElement('embed');
                     embed.setAttribute('src', blobUrl);
                     embed.setAttribute('type', 'application/pdf');
@@ -3606,7 +3600,18 @@ window.PrivateBin = (function () {
             // if an avatar is available, display it
             const icon = comment.getIcon();
             if (icon) {
+                const iconTitle = 'Avatar generated from IP address';
                 const image = document.createElement('img');
+                // Set the title immediately with the current translation. If
+                // the language has not been loaded yet, this will fall back to
+                // English, so we also register a callback to re-apply the
+                // translation once available. To avoid a race condition, we
+                // register the callback first, then translate - if the language
+                // loads between the two lines, both will set the translated text.
+                I18n.onLanguageLoaded(function () {
+                    image.setAttribute('title', I18n._(iconTitle));
+                });
+                image.setAttribute('title', I18n._(iconTitle));
                 image.setAttribute('src', icon);
                 image.setAttribute('class', 'vizhash');
                 const nickSpan = commentEntry.querySelector('span.nickname');
@@ -4038,6 +4043,31 @@ window.PrivateBin = (function () {
             Controller.newPaste();
         }
 
+        /**
+         * sends the document on [Ctrl]+[S] or, on macOS, [Cmd]+[S]
+         *
+         * Only acts while the send button is shown, so the browser's own
+         * "save page" still works when viewing an existing document.
+         *
+         * @name   TopNav.sendOnShortcut
+         * @private
+         * @function
+         * @param  {KeyboardEvent} event
+         */
+        function sendOnShortcut(event) {
+            // the physical S key, so non-Latin keyboard layouts work, too
+            const isSaveKey = event.code === 'KeyS';
+            // Alt is excluded because AltGr is reported as Ctrl+Alt and some
+            // layouts type letters with AltGr+S, Shift to leave "Save page as"
+            // ([Ctrl]+[Shift]+[S]) to the browser
+            const hasCorrectModifiers = (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey;
+            const isButtonAvailable = sendButton && !sendButton.classList.contains('hidden') && !sendButton.disabled;
+            if (isSaveKey && hasCorrectModifiers && isButtonAvailable) {
+                event.preventDefault();
+                sendButton.click();
+            }
+        }
+
         /**
          * retrys some callback registered before
          *
@@ -4146,11 +4176,12 @@ window.PrivateBin = (function () {
          *
          * @name   TopNav.triggerEmailSend
          * @private
+         * @param {string} subject
          * @param {string} emailBody
          */
-        function triggerEmailSend(emailBody) {
+        function triggerEmailSend(subject, emailBody) {
             window.open(
-                `mailto:?body=${encodeURIComponent(emailBody)}`,
+                `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(emailBody)}`,
                 '_self',
                 'noopener, noreferrer'
             );
@@ -4174,6 +4205,9 @@ window.PrivateBin = (function () {
             );
             expirationDateRoundedToSecond.setUTCSeconds(0);
 
+            // reused as-is by both the confirmation-modal and direct-send paths below
+            const emailSubject = I18n._('Encrypted note on %s', document.title);
+
             const emailconfirmmodal = document.getElementById('emailconfirmmodal');
             if (expirationDate !== null) {
                 const emailconfirmTimezoneCurrent = emailconfirmmodal.querySelector('#emailconfirm-timezone-current');
@@ -4192,7 +4226,7 @@ window.PrivateBin = (function () {
                     if (bootstrap5EmailConfirmModal) {
                         bootstrap5EmailConfirmModal.hide();
                     }
-                    triggerEmailSend(emailBody);
+                    triggerEmailSend(emailSubject, emailBody);
                 }
 
                 emailconfirmmodal.addEventListener('shown.bs.modal', () => {
@@ -4210,7 +4244,7 @@ window.PrivateBin = (function () {
                     bootstrap5EmailConfirmModal.show();
                 }
             } else {
-                triggerEmailSend(templateEmailBody(null, isBurnafterreading));
+                triggerEmailSend(emailSubject, templateEmailBody(null, isBurnafterreading));
             }
         }
 
@@ -4284,8 +4318,12 @@ window.PrivateBin = (function () {
             expiration.classList.remove('hidden');
             formatter.classList.remove('hidden');
             newButton.classList.remove('hidden');
-            openDiscussionOption.classList.remove('hidden');
-            password.classList.remove('hidden');
+            if (openDiscussionOption) {
+                openDiscussionOption.classList.remove('hidden');
+            }
+            if (password) {
+                password.classList.remove('hidden');
+            }
             sendButton.classList.remove('hidden');
 
             createButtonsDisplayed = true;
@@ -4307,8 +4345,12 @@ window.PrivateBin = (function () {
             expiration.classList.add('hidden');
             formatter.classList.add('hidden');
             burnAfterReadingOption.classList.add('hidden');
-            openDiscussionOption.classList.add('hidden');
-            password.classList.add('hidden');
+            if (openDiscussionOption) {
+                openDiscussionOption.classList.add('hidden');
+            }
+            if (password) {
+                password.classList.add('hidden');
+            }
             if (attach) {
                 attach.classList.add('hidden');
             }
@@ -4334,6 +4376,11 @@ window.PrivateBin = (function () {
          */
         me.showRetryButton = function () {
             retryButton.classList.remove('hidden');
+            // The Bootstrap 5 template gives Retry its own list item. That item
+            // still takes a flex gap while only the button is hidden.
+            if (Helper.isBootstrap5()) {
+                retryButton.parentElement.classList.remove('hidden');
+            }
         };
 
         /**
@@ -4344,6 +4391,9 @@ window.PrivateBin = (function () {
          */
         me.hideRetryButton = function () {
             retryButton.classList.add('hidden');
+            if (Helper.isBootstrap5()) {
+                retryButton.parentElement.classList.add('hidden');
+            }
         };
 
         /**
@@ -4352,15 +4402,17 @@ window.PrivateBin = (function () {
          * @name   TopNav.showEmailbutton
          * @function
          * @param {number|undefined} optionalRemainingTimeInSeconds
+         * @param {bool|undefined} optionalBurnAfterReading
          */
-        me.showEmailButton = function (optionalRemainingTimeInSeconds) {
+        me.showEmailButton = function (optionalRemainingTimeInSeconds, optionalBurnAfterReading) {
             try {
                 // we cache expiration date in closure to avoid inaccurate expiration datetime
                 const expirationDate = Helper.calculateExpirationDate(
                     new Date(),
                     typeof optionalRemainingTimeInSeconds === 'number' ? optionalRemainingTimeInSeconds : TopNav.getExpiration()
                 );
-                const isBurnafterreading = TopNav.getBurnAfterReading();
+                const isBurnafterreading = typeof optionalBurnAfterReading === 'boolean' ?
+                    optionalBurnAfterReading : TopNav.getBurnAfterReading();
 
                 emailLink.classList.remove('hidden');
                 emailLink.removeEventListener('click', sendEmail);
@@ -4512,7 +4564,7 @@ window.PrivateBin = (function () {
             if (openDiscussion) {
                 openDiscussion.checked = openDiscussionDefault;
             }
-            if (openDiscussionDefault || !burnAfterReadingDefault) {
+            if (openDiscussionOption && (openDiscussionDefault || !burnAfterReadingDefault)) {
                 openDiscussionOption.classList.remove('buttondisabled');
             }
             if (burnAfterReadingDefault || !openDiscussionDefault) {
@@ -4720,6 +4772,7 @@ window.PrivateBin = (function () {
             }
             if (sendButton) {
                 sendButton.addEventListener('click', PasteEncrypter.sendPaste);
+                document.addEventListener('keydown', sendOnShortcut);
             }
             if (cloneButton) {
                 cloneButton.addEventListener('click', Controller.clonePaste);
@@ -5501,16 +5554,6 @@ window.PrivateBin = (function () {
                         plaintexts[i][1]
                     );
                 }
-
-                document.addEventListener(I18n.languageLoadedEvent, function () {
-                    const commentContainer = document.getElementById('commentcontainer');
-                    if (!commentContainer) {
-                        return;
-                    }
-
-                    commentContainer.querySelectorAll('img.vizhash')
-                        .forEach(img => img.setAttribute('title', I18n._('Avatar generated from IP address')));
-                });
             });
         }
 
@@ -5567,7 +5610,10 @@ window.PrivateBin = (function () {
                         TopNav.hideBurnAfterReadingButtons();
                     } else {
                         // we have to pass in remaining_time here
-                        TopNav.showEmailButton(paste.getTimeToLive());
+                        TopNav.showEmailButton(
+                            paste.getTimeToLive(),
+                            paste.isBurnAfterReadingEnabled()
+                        );
                     }
 
                     // only offer adding comments, after document was successfully decrypted
@@ -6138,4 +6184,3 @@ if (typeof module === 'undefined' || !module.exports) {
         window.PrivateBin.Controller.init();
     });
 }
-

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 1
js/purify-3.4.12.js


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 4 - 0
js/purify-3.4.17.js


+ 120 - 35
js/test/AttachmentViewer.js

@@ -1,5 +1,13 @@
 'use strict';
 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 () {
@@ -30,7 +38,7 @@ describe('AttachmentViewer', function () {
                             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
@@ -39,16 +47,8 @@ describe('AttachmentViewer', function () {
                     }
                     prefix  = prefix.replace(/%(s|d)/g, '%%');
                     postfix = postfix.replace(/%(s|d)/g, '%%').replace(/<|>/g, '');
-                    document.body.innerHTML = (
-                        '<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>'
-                    );
+                    document.body.innerHTML = bodyTemplate;
+                    mockCreateObjectUrl(false);
                     PrivateBin.AttachmentViewer.init();
                     PrivateBin.Model.init();
                     results.push(
@@ -131,18 +131,9 @@ describe('AttachmentViewer', function () {
         });
 
         it(
-            'sanitizes file names in attachments',
+            'sanitizes file names',
             function() {
-                document.body.innerHTML = (
-                    '<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>'
-                );
+                document.body.innerHTML = bodyTemplate;
                 PrivateBin.AttachmentViewer.init();
                 PrivateBin.Model.init();
                 global.atob = common.atob;
@@ -154,6 +145,103 @@ describe('AttachmentViewer', function () {
                 for (const filename of maliciousFileNames) {
                     PrivateBin.AttachmentViewer.setAttachment('data:;base64,', filename);
                     assert.ok(!document.body.innerHTML.includes(filename));
+                    PrivateBin.AttachmentViewer.removeAttachment();
+                }
+            }
+        );
+
+        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();
+                }
+            }
+        );
+
+        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);
+                }
+            }
+        );
+
+        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();
+                }
+            }
+        );
+
+        it(
+            '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();
                 }
             }
         );
@@ -190,19 +278,16 @@ describe('AttachmentViewer', function () {
         )
     });
 
-    function mockCreateObjectUrl() {
-        if (typeof window.URL.createObjectURL === 'undefined') {
-            Object.defineProperty(
-                window.URL,
-                'createObjectURL',
-                {
-                    value: function (_blob) {
-                        return 'blob:' + location.origin + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
-                    }
+    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';
                 }
-            );
-        }
+            }
+        );
     }
 });
-
-

+ 4 - 2
js/test/CopyToClipboard.js

@@ -3,6 +3,8 @@ const common = require('../common');
 const fc = require('fast-check');
 
 describe('CopyToClipboard', function () {
+    this.timeout(30000);
+
     afterEach(() => {
         globalThis.cleanup();
     });
@@ -43,7 +45,7 @@ describe('CopyToClipboard', function () {
 
                     return text === savedToClipboardText;
                 }
-            ));
+            ), { numRuns: 20 });
         });
 
         /**
@@ -85,7 +87,7 @@ describe('CopyToClipboard', function () {
 
                     return copiedTextWithoutSelectedText === text;
                 }
-            ));
+            ), { numRuns: 20 });
         });
 
         /**

+ 85 - 16
js/test/I18n.js

@@ -14,24 +14,24 @@ describe('I18n', function () {
                 fc.string(),
                 function (messageId) {
                     messageId   = messageId.replace(/%(s|d)/g, '%%');
-                    var plurals = [messageId, messageId + 's'],
-                        fake    = [messageId],
-                        result  = PrivateBin.I18n.translate(messageId);
+                    const plurals = [messageId, messageId + 's'],
+                          fake    = [messageId],
+                          result  = PrivateBin.I18n.translate(messageId);
                     PrivateBin.I18n.reset();
 
-                    var alias = PrivateBin.I18n._(messageId);
+                    const alias = PrivateBin.I18n._(messageId);
                     PrivateBin.I18n.reset();
 
-                    var pluralResult = PrivateBin.I18n.translate(plurals);
+                    const pluralResult = PrivateBin.I18n.translate(plurals);
                     PrivateBin.I18n.reset();
 
-                    var pluralAlias = PrivateBin.I18n._(plurals);
+                    const pluralAlias = PrivateBin.I18n._(plurals);
                     PrivateBin.I18n.reset();
 
-                    var fakeResult = PrivateBin.I18n.translate(fake);
+                    const fakeResult = PrivateBin.I18n.translate(fake);
                     PrivateBin.I18n.reset();
 
-                    var fakeAlias = PrivateBin.I18n._(fake);
+                    const fakeAlias = PrivateBin.I18n._(fake);
                     PrivateBin.I18n.reset();
 
                     if (messageId.indexOf('<a') === -1) {
@@ -177,12 +177,23 @@ describe('I18n', function () {
                 fc.integer(),
                 function(language, n) {
                     PrivateBin.I18n.reset(language);
-                    var result = PrivateBin.I18n.getPluralForm(n);
+                    const result = PrivateBin.I18n.getPluralForm(n);
                     // arabic seems to have the highest plural count with 6 forms
                     return result >= 0 && result <= 5;
                 }
             ));
         });
+
+        it('routes Lithuanian cardinal forms by the last digit', () => {
+            PrivateBin.I18n.reset('lt');
+            assert.strictEqual(PrivateBin.I18n.getPluralForm(1), 0);
+            assert.strictEqual(PrivateBin.I18n.getPluralForm(2), 1);
+            assert.strictEqual(PrivateBin.I18n.getPluralForm(10), 2);
+            assert.strictEqual(PrivateBin.I18n.getPluralForm(22), 1);
+            assert.strictEqual(PrivateBin.I18n.getPluralForm(110), 2);
+            assert.strictEqual(PrivateBin.I18n.getPluralForm(111), 2);
+            assert.strictEqual(PrivateBin.I18n.getPluralForm(120), 2);
+        });
     });
 
     // loading of JSON via AJAX needs to be tested in the browser, this just mocks it
@@ -198,7 +209,7 @@ describe('I18n', function () {
                 common.fcSupportedLanguages(),
                 function(language) {
                     // cleanup
-                    var clean = globalThis.cleanup('', {cookie: ['lang=en']});
+                    let clean = globalThis.cleanup('', {cookie: ['lang=en']});
                     PrivateBin.I18n.reset('en');
                     PrivateBin.I18n.loadTranslations();
                     clean();
@@ -207,17 +218,59 @@ describe('I18n', function () {
                     clean = globalThis.cleanup('', {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');
+                    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/'});
+
+            // 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();
+            }
+        });
+
         it('should default to en', () => {
-            var clean = globalThis.cleanup('', {url: 'https://privatebin.net/'});
+            const clean = globalThis.cleanup('', {url: 'https://privatebin.net/'});
 
             // when navigator.userLanguage is undefined and no default language
             // is specified, it would throw an error
@@ -232,11 +285,27 @@ describe('I18n', function () {
 
             PrivateBin.I18n.reset('en');
             PrivateBin.I18n.loadTranslations();
-            var result = PrivateBin.I18n.translate('Never'),
-                alias  = PrivateBin.I18n._('Never');
+            const result = PrivateBin.I18n.translate('Never'),
+                  alias  = PrivateBin.I18n._('Never');
 
             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);
+        });
+    });
 });

+ 24 - 1
js/test/Model.js

@@ -122,6 +122,30 @@ describe('Model', function () {
                 }
             ));
         });
+        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();
+            });
+        });
     });
 
     describe('getPasteKey', function () {
@@ -254,4 +278,3 @@ describe('Model', function () {
         });
     });
 });
-

+ 32 - 0
js/test/Prompt.js

@@ -28,12 +28,43 @@ describe('Prompt', function () {
                                 </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;
@@ -46,6 +77,7 @@ describe('Prompt', function () {
 
                     // Verify that getPassword returns the submitted password
                     const result = PrivateBin.Prompt.getPassword();
+                    globalThis.fetch = originalFetch;
                     clean();
                     return result === password;
                 }

+ 210 - 0
js/test/TopNav.js

@@ -125,6 +125,113 @@ describe('TopNav', function () {
                 assert.ok(result);
             }
         );
+
+        it(
+            'tolerates missing optional elements (discussion & password disabled)',
+            function () {
+                // with discussion = false and password = false in the
+                // configuration, the template omits these elements entirely
+                document.documentElement.innerHTML =
+                    '<nav><div id="navbar"><ul><li><button id="newbutton" ' +
+                    'type="button" class="hidden">New</button></li><li><a ' +
+                    'id="expiration" href="#" class="hidden">Expiration</a>' +
+                    '</li><li><div id="burnafterreadingoption" class="hidden">' +
+                    'Burn after reading</div></li><li>' +
+                    '<a id="formatter" href="#" class="hidden">Format</a>' +
+                    '</li><li><button id="sendbutton" type="button" ' +
+                    'class="hidden">Create</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
+                assert.doesNotThrow(() => PrivateBin.TopNav.showCreateButtons());
+                assert.ok(!query('#sendbutton').classList.contains('hidden'));
+                assert.ok(!query('#burnafterreadingoption').classList.contains('hidden'));
+                assert.doesNotThrow(() => PrivateBin.TopNav.hideCreateButtons());
+                assert.ok(query('#sendbutton').classList.contains('hidden'));
+                assert.ok(query('#burnafterreadingoption').classList.contains('hidden'));
+                cleanup();
+            }
+        );
+    });
+
+    describe('send shortcut', function () {
+        let sendCount;
+
+        // builds the create-document navbar and stubs the actual send, so no
+        // server interaction happens
+        function setUpCreateMode() {
+            cleanup();
+            sendCount = 0;
+            document.documentElement.innerHTML =
+                '<nav><div id="navbar"><ul><li><button id="newbutton" ' +
+                'type="button" class="hidden">New</button></li><li><a ' +
+                'id="expiration" href="#" class="hidden">Expiration</a>' +
+                '</li><li><div id="burnafterreadingoption" class="hidden">' +
+                'Burn after reading</div></li><li><div id="opendiscussion' +
+                'option" class="hidden">Open discussion</div></li><li>' +
+                '<div id="password" class="hidden">Password</div></li>' +
+                '<li id="attach" class="hidden">Attach a file</li><li>' +
+                '<a id="formatter" href="#" class="hidden">Format</a>' +
+                '</li><li><button id="sendbutton" type="button" ' +
+                'class="hidden">Create</button></li></ul></div></nav>';
+            PrivateBin.PasteEncrypter.sendPaste = function () {
+                sendCount++;
+            };
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showCreateButtons();
+        }
+
+        // dispatches a keydown on the document, returns whether the default was prevented
+        function pressKey(options) {
+            const event = new window.KeyboardEvent('keydown', Object.assign(
+                {bubbles: true, cancelable: true}, options
+            ));
+            document.dispatchEvent(event);
+            return event.defaultPrevented;
+        }
+
+        afterEach(function () {
+            cleanup();
+        });
+
+        it('sends the document on Ctrl+S and suppresses the browser save dialog', function () {
+            setUpCreateMode();
+            const prevented = pressKey({key: 's', code: 'KeyS', ctrlKey: true});
+            assert.strictEqual(sendCount, 1);
+            assert.ok(prevented);
+        });
+
+        it('sends the document on Cmd+S (macOS)', function () {
+            setUpCreateMode();
+            const prevented = pressKey({key: 's', code: 'KeyS', metaKey: true});
+            assert.strictEqual(sendCount, 1);
+            assert.ok(prevented);
+        });
+
+        it('sends on the physical S key of non-Latin keyboard layouts', function () {
+            setUpCreateMode();
+            // on a Russian layout the S key produces "ы"
+            pressKey({key: 'ы', code: 'KeyS', ctrlKey: true});
+            assert.strictEqual(sendCount, 1);
+        });
+
+        it('leaves Ctrl+S to the browser when viewing a document', function () {
+            setUpCreateMode();
+            PrivateBin.TopNav.hideCreateButtons();
+            const prevented = pressKey({key: 's', code: 'KeyS', ctrlKey: true});
+            assert.strictEqual(sendCount, 0);
+            assert.ok(!prevented);
+        });
+
+        it('ignores S without Ctrl/Cmd, with AltGr (Ctrl+Alt), with Shift, or other keys', function () {
+            setUpCreateMode();
+            const prevented = [
+                pressKey({key: 's', code: 'KeyS'}),
+                pressKey({key: 's', code: 'KeyS', ctrlKey: true, altKey: true}),
+                pressKey({key: 'S', code: 'KeyS', ctrlKey: true, shiftKey: true}),
+                pressKey({key: 'd', code: 'KeyD', ctrlKey: true})
+            ];
+            assert.strictEqual(sendCount, 0);
+            assert.deepStrictEqual(prevented, [false, false, false, false]);
+        });
     });
 
     describe('showNewPasteButton', function () {
@@ -484,6 +591,21 @@ describe('TopNav', function () {
                 assert.ok(result);
             }
         );
+
+        it(
+            'tolerates a missing discussion option (discussion disabled)',
+            function () {
+                document.documentElement.innerHTML =
+                    '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
+                    'class="buttondisabled"><label><input type="checkbox" ' +
+                    'id="burnafterreading" name="burnafterreading" /> ' +
+                    'Burn after reading</label></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
+                assert.doesNotThrow(() => PrivateBin.TopNav.resetInput());
+                assert.ok(!query('#burnafterreadingoption').classList.contains('buttondisabled'));
+                cleanup();
+            }
+        );
     });
 
     describe('getExpiration', function () {
@@ -774,6 +896,94 @@ describe('TopNav', function () {
         );
     });
 
+    describe('showRetryButton & hideRetryButton', function () {
+        afterEach(function () {
+            delete global.bootstrap;
+            cleanup();
+        });
+
+        it(
+            'shows and hides the Bootstrap 5 retry button with its list item and runs the callback once',
+            function () {
+                cleanup();
+                global.bootstrap = {};
+                document.documentElement.innerHTML =
+                    '<nav><div id="navbar"><ul class="navbar-nav">' +
+                    '<li class="nav-item hidden"><button id="retrybutton" ' +
+                    'type="button" class="hidden">Retry</button></li>' +
+                    '<li><button id="newbutton" type="button">New</button>' +
+                    '</li></ul></div></nav>';
+                PrivateBin.TopNav.init();
+                let calls = 0;
+                PrivateBin.TopNav.setRetryCallback(function () {
+                    calls++;
+                });
+                const retryItem = query('#retrybutton').parentElement;
+
+                assert.ok(query('#retrybutton').classList.contains('hidden'));
+                assert.ok(retryItem.classList.contains('hidden'));
+
+                PrivateBin.TopNav.showRetryButton();
+                assert.ok(!query('#retrybutton').classList.contains('hidden'));
+                assert.ok(!retryItem.classList.contains('hidden'));
+
+                query('#retrybutton').click();
+                assert.strictEqual(calls, 1);
+
+                PrivateBin.TopNav.hideRetryButton();
+                assert.ok(query('#retrybutton').classList.contains('hidden'));
+                assert.ok(retryItem.classList.contains('hidden'));
+
+                PrivateBin.TopNav.showRetryButton();
+                PrivateBin.TopNav.hideRetryButton();
+                assert.ok(query('#retrybutton').classList.contains('hidden'));
+                assert.ok(retryItem.classList.contains('hidden'));
+                assert.ok(!query('#newbutton').classList.contains('hidden'));
+                assert.strictEqual(calls, 1);
+            }
+        );
+
+        it(
+            'toggles only the retry button on legacy Bootstrap 3 and page templates',
+            function () {
+                cleanup();
+                delete global.bootstrap;
+                document.documentElement.innerHTML =
+                    '<nav><div id="navbar"><ul class="nav navbar-nav"><li>' +
+                    '<button id="retrybutton" type="button" class="hidden">' +
+                    'Retry</button><button id="newbutton" type="button">' +
+                    'New</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
+                let calls = 0;
+                PrivateBin.TopNav.setRetryCallback(function () {
+                    calls++;
+                });
+                const retryItem = query('#retrybutton').parentElement;
+
+                assert.ok(query('#retrybutton').classList.contains('hidden'));
+                assert.ok(!retryItem.classList.contains('hidden'));
+
+                PrivateBin.TopNav.showRetryButton();
+                assert.ok(!query('#retrybutton').classList.contains('hidden'));
+                assert.ok(!retryItem.classList.contains('hidden'));
+                assert.ok(!query('#newbutton').classList.contains('hidden'));
+
+                query('#retrybutton').click();
+                assert.strictEqual(calls, 1);
+
+                PrivateBin.TopNav.hideRetryButton();
+                assert.ok(query('#retrybutton').classList.contains('hidden'));
+                assert.ok(!retryItem.classList.contains('hidden'));
+                assert.ok(!query('#newbutton').classList.contains('hidden'));
+
+                PrivateBin.TopNav.showRetryButton();
+                PrivateBin.TopNav.hideRetryButton();
+                assert.ok(query('#retrybutton').classList.contains('hidden'));
+                assert.strictEqual(calls, 1);
+            }
+        );
+    });
+
     describe('rawText', function () {
         before(function () {
             cleanup();

+ 115 - 3
js/test/emailTemplateTest.js

@@ -81,10 +81,20 @@ function makeWindowOpenMock() {
 }
 
 
-// Extract and decode the body from a "mailto:?body=..." URL.
+// Extract and decode the body from a "mailto:?subject=...&body=..." URL.
 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 () {
@@ -139,4 +149,106 @@ describe('Email - mail body content (short URL vs. fallback)', function () {
             restore();
         }
     });
+
+    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();
+        }
+    });
 });

+ 6 - 2
lib/Configuration.php

@@ -112,6 +112,10 @@ class Configuration
             'apikey'    => '',
             'apiurl'    => '',
         ],
+        'chhoto' => [
+            'apikey'    => '',
+            'apiurl'    => '',
+        ],
         // update this array when adding/changing/removing js files
         'sri' => [
             'js/base-x-5.0.1.js'     => 'sha512-FmhlnjIxQyxkkxQmzf0l6IRGsGbgyCdgqPxypFsEtHMF1naRqaLLo6mcyN5rEaT16nKx1PeJ4g7+07D6gnk/Tg==',
@@ -120,8 +124,8 @@ class Configuration
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
             'js/legacy.js'           => 'sha512-pRofxsrf5UItjiP22Dcjh3FAcBjF/n7h8U9/W5xqJk17U0N2U1oajhXypq/omo9jhwS1iVGOhWrRepoPeFns+w==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
-            'js/privatebin.js'       => 'sha512-t6ydK3dllX48jNWnOCf6GRnycx5jwkMVHBwmFYvMVWpgKuc4LjrXesuwhVMbm5aHlVRFG6y4HkORfv2wLaXvSA==',
-            'js/purify-3.4.12.js'    => 'sha512-Akf6HnAJZm0sWWWI4gp2GYff0NDnHUB02XJE5S7Hdq/Z5xtMjkuFacsDA8ZtViv1gi+onBxMhEMIaGyQeGxBng==',
+            'js/privatebin.js'       => 'sha512-RHO7+eilgNEa2UxlYWKx5lv6GB1oYBW15E7c/L/3QSDMhp2WVfjG5LOxF73atYxJgH1MwWO77IMe8iRtDeCbGg==',
+            'js/purify-3.4.17.js'    => 'sha512-udF2zPdEyVCAxIGeBbeSGFK1Fj5c4eHGi6NqOSsr8M75lGs39POzgUNxO3SFxOg4cHfAoZJgR2EaXdmDi6v6AQ==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',
             'js/zlib.js'             => 'sha512-QOaEwssHqHRRcWJ2Un3Kl2Zhyprzl7T8zmsKN2FppFxW3VR+8UChYOx2iuL0HbXK42fuBWJm5PNQJxufulrt/w==',

+ 13 - 4
lib/Controller.php

@@ -16,6 +16,7 @@ use PrivateBin\Exception\TranslatedException;
 use PrivateBin\Persistence\ServerSalt;
 use PrivateBin\Persistence\TrafficLimiter;
 use PrivateBin\Proxy\AbstractProxy;
+use PrivateBin\Proxy\ChhotoProxy;
 use PrivateBin\Proxy\ShlinkProxy;
 use PrivateBin\Proxy\YourlsProxy;
 
@@ -31,7 +32,7 @@ class Controller
      *
      * @const string
      */
-    const VERSION = '2.0.5';
+    const VERSION = '2.1.0';
 
     /**
      * minimal required PHP version
@@ -158,6 +159,9 @@ class Controller
             case 'shlinkproxy':
                 $this->_shortenerproxy(new ShlinkProxy($this->_conf, $this->_request->getParam('link')));
                 break;
+            case 'chhotoproxy':
+                $this->_shortenerproxy(new ChhotoProxy($this->_conf, $this->_request->getParam('link')));
+                break;
         }
 
         $this->_setCacheHeaders();
@@ -168,6 +172,7 @@ class Controller
             header('Access-Control-Allow-Origin: *');
             header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE');
             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('Access-Control-Expose-Headers: X-Uncompressed-Content-Length');
             echo $this->_json;
@@ -203,7 +208,10 @@ class Controller
         $lang = $this->_conf->getKey('languagedefault');
         I18n::setLanguageFallback($lang);
         // 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;
             setcookie('lang', $lang, ['SameSite' => 'Lax', 'Secure' => true]);
         }
@@ -453,7 +461,7 @@ class Controller
         $page->assign('CSPHEADER', $metacspheader);
         $page->assign('ERROR', I18n::_($this->_error));
         $page->assign('NAME', $this->_conf->getKey('name'));
-        if (in_array($this->_request->getOperation(), ['shlinkproxy', 'yourlsproxy'], true)) {
+        if (in_array($this->_request->getOperation(), ['shlinkproxy', 'yourlsproxy', 'chhotoproxy'], true)) {
             $page->assign('SHORTURL', $this->_status);
             $page->draw('shortenerproxy');
             return;
@@ -513,7 +521,7 @@ class Controller
         if (is_readable($file)) {
             $content = str_replace(
                 '?jsonld=',
-                $this->_urlBase . '?jsonld=',
+                trim(Json::encode($this->_urlBase), '"') . '?jsonld=',
                 file_get_contents($file)
             );
         }
@@ -528,6 +536,7 @@ class Controller
         header('Content-type: application/ld+json');
         header('Access-Control-Allow-Origin: *');
         header('Access-Control-Allow-Methods: GET');
+        header('X-Content-Type-Options: nosniff');
         echo $content;
     }
 

+ 13 - 0
lib/Data/AbstractData.php

@@ -195,4 +195,17 @@ abstract class AbstractData
         }
         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;
+    }
 }

+ 1 - 2
lib/Data/Database.php

@@ -325,9 +325,8 @@ class Database extends AbstractData
                     $comments[$i]['meta']['icon'] = $row['vizhash'];
                 }
             }
-            ksort($comments);
         }
-        return $comments;
+        return $this->sortComments($comments);
     }
 
     /**

+ 50 - 20
lib/Data/Filesystem.php

@@ -23,6 +23,23 @@ use PrivateBin\Json;
  */
 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
      * 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]' .
         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
@@ -46,13 +62,6 @@ class Filesystem extends AbstractData
      */
     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
      *
@@ -163,8 +172,10 @@ class Filesystem extends AbstractData
             // convert comments, too
             $discdir = $this->_dataid2discussionpath($pasteid);
             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');
                     }
                 }
@@ -208,14 +219,23 @@ class Filesystem extends AbstractData
         $comments = [];
         $discdir  = $this->_dataid2discussionpath($pasteid);
         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:
                 // - pasteid is the paste this reply belongs to.
                 // - commentid is the comment identifier itself.
                 // - parentid is the comment this comment replies to (It can be pasteid)
                 if ($file->isFile()) {
                     $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.
                     $comment['id']       = $items[1];
                     $comment['parentid'] = $items[2];
@@ -228,11 +248,8 @@ class Filesystem extends AbstractData
                     $comments[$key] = $comment;
                 }
             }
-
-            // Sort comments by date, oldest first.
-            ksort($comments);
         }
-        return $comments;
+        return $this->sortComments($comments);
     }
 
     /**
@@ -516,10 +533,23 @@ class Filesystem extends AbstractData
     {
         // don't overwrite already converted file
         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);
+            chmod($destFile, 0640); // protect file from access by other users on the host
+            if ($written === false) {
+                return;
+            }
         }
         if (!unlink($srcFile)) {
             error_log('Error deleting converted document: ' . $srcFile);

+ 1 - 1
lib/Data/GoogleCloudStorage.php

@@ -233,7 +233,7 @@ class GoogleCloudStorage extends AbstractData
         } catch (NotFoundException $e) {
             // no comments found
         }
-        return $comments;
+        return $this->sortComments($comments);
     }
 
     /**

+ 1 - 1
lib/Data/S3Storage.php

@@ -299,7 +299,7 @@ class S3Storage extends AbstractData
         } catch (S3Exception $e) {
             // no comments found
         }
-        return $comments;
+        return $this->sortComments($comments);
     }
 
     /**

+ 86 - 20
lib/I18n.php

@@ -14,6 +14,9 @@ namespace PrivateBin;
 use AppendIterator;
 use GlobIterator;
 
+use function array_key_exists;
+use function in_array;
+
 /**
  * I18n
  *
@@ -116,8 +119,8 @@ class I18n
         array_unshift($args, $messageId);
         if (is_array(self::$_translations[$messageId])) {
             $number = (int) $args[1];
-            $key    = self::_getPluralForm($number);
-            $max    = count(self::$_translations[$messageId]) - 1;
+            $key = self::_getPluralForm($number);
+            $max = count(self::$_translations[$messageId]) - 1;
             if ($key > $max) {
                 $key = $max;
             }
@@ -176,20 +179,47 @@ class I18n
         }
         // find a translation file matching the browsers language preferences
         else {
-            self::$_language = self::_getMatchingLanguage(
-                self::getBrowserLanguages(), $availableLanguages
-            );
+            self::$_language = self::_getLanguageFromHttpHeader($availableLanguages);
         }
 
         // load translations
         if (self::$_language === 'en') {
             self::$_translations = [];
         } else {
-            $data                = file_get_contents(self::_getPath(self::$_language . '.json'));
+            $data = file_get_contents(self::_getPath(self::$_language . '.json'));
             self::$_translations = Json::decode($data);
         }
     }
 
+    /**
+     * get languages bsed on the HTTP header
+     *
+     * @access private
+     * @static
+     * @param  array $availableLanguages
+     * @return string
+     */
+    private static function _getLanguageFromHttpHeader($availableLanguages)
+    {
+        if (!array_key_exists('HTTP_ACCEPT_LANGUAGE', $_SERVER)) {
+            return self::$_languageFallback;
+        }
+
+        // as per https://stackoverflow.com/a/9051957/5008962 we can use the I10n module
+        // if loaded, but it only returns the primary preference (aka one string)
+        if (function_exists('locale_accept_from_http')) {
+            $detectedBestLanguage = locale_accept_from_http($_SERVER['HTTP_ACCEPT_LANGUAGE']);
+            if (in_array($detectedBestLanguage, $availableLanguages)) {
+                return $detectedBestLanguage;
+            }
+        }
+
+        return self::_getMatchingLanguage(
+            self::_normalizeBrowserLanguages(self::getBrowserLanguages(), $availableLanguages),
+            $availableLanguages
+        );
+    }
+
     /**
      * get list of available translations based on files found
      *
@@ -201,9 +231,10 @@ class I18n
     {
         if (count(self::$_availableLanguages) === 0) {
             self::$_availableLanguages[] = 'en'; // en.json is not part of the release archive
-            $languageIterator            = new AppendIterator();
+            $languageIterator = new AppendIterator();
             $languageIterator->append(new GlobIterator(self::_getPath('??.json')));
             $languageIterator->append(new GlobIterator(self::_getPath('???.json'))); // for jbo
+            $languageIterator->append(new GlobIterator(self::_getPath('??-??.json'))); // for regional variants like zh-tw
             foreach ($languageIterator as $file) {
                 $language = $file->getBasename('.json');
                 if ($language !== 'en') {
@@ -230,7 +261,7 @@ class I18n
             $languageRanges = explode(',', trim($_SERVER['HTTP_ACCEPT_LANGUAGE']));
             foreach ($languageRanges as $languageRange) {
                 if (preg_match(
-                    '/(\*|[a-zA-Z0-9]{1,8}(?:-[a-zA-Z0-9]{1,8})*)(?:\s*;\s*q\s*=\s*(0(?:\.\d{0,3})|1(?:\.0{0,3})))?/',
+                    '/^(\*|[a-zA-Z0-9]{1,8}(?:-[a-zA-Z0-9]{1,8})*)(?:\s*;\s*q\s*=\s*(0(?:\.\d{0,3})|1(?:\.0{0,3})))?$/',
                     trim($languageRange), $match
                 )) {
                     if (!isset($match[2])) {
@@ -249,6 +280,39 @@ class I18n
         return $languages;
     }
 
+    /**
+     * normalize browser language aliases to available locale IDs
+     *
+     * @access protected
+     * @static
+     * @param  array $languages
+     * @param  array $availableLanguages
+     * @return array
+     */
+    protected static function _normalizeBrowserLanguages($languages, $availableLanguages)
+    {
+        // Base zh stays before zh-tw because regional locales are appended after base locales.
+        $hasSimplifiedChinese = in_array('zh', $availableLanguages, true);
+        $hasTraditionalChinese = in_array('zh-tw', $availableLanguages, true);
+        foreach ($languages as $quality => $languageRanges) {
+            foreach ($languageRanges as $index => $languageRange) {
+                $isSimplifiedChinese = $languageRange === 'zh-hans' || str_starts_with($languageRange, 'zh-hans-') ||
+                    $languageRange === 'zh-cn' || str_starts_with($languageRange, 'zh-cn-') ||
+                    $languageRange === 'zh-sg' || str_starts_with($languageRange, 'zh-sg-');
+                $isTraditionalChinese = str_starts_with($languageRange, 'zh-tw-') ||
+                    $languageRange === 'zh-hant' || str_starts_with($languageRange, 'zh-hant-') ||
+                    $languageRange === 'zh-hk' || str_starts_with($languageRange, 'zh-hk-') ||
+                    $languageRange === 'zh-mo' || str_starts_with($languageRange, 'zh-mo-');
+                if ($hasSimplifiedChinese && $isSimplifiedChinese) {
+                    $languages[$quality][$index] = 'zh';
+                } elseif ($hasTraditionalChinese && $isTraditionalChinese) {
+                    $languages[$quality][$index] = 'zh-tw';
+                }
+            }
+        }
+        return $languages;
+    }
+
     /**
      * get currently loaded language
      *
@@ -276,7 +340,7 @@ class I18n
     {
         $file = self::_getPath('languages.json');
         if (count(self::$_languageLabels) === 0 && is_readable($file)) {
-            $data                  = file_get_contents($file);
+            $data = file_get_contents($file);
             self::$_languageLabels = Json::decode($data);
         }
         if (count($languages) === 0) {
@@ -294,7 +358,7 @@ class I18n
      */
     public static function isRtl()
     {
-        return in_array(self::$_language, ['ar', 'he']);
+        return in_array(self::$_language, ['ar', 'fa', 'he']);
     }
 
     /**
@@ -306,8 +370,8 @@ class I18n
      */
     public static function getCopyHotkey()
     {
-        return isset($_SERVER['HTTP_USER_AGENT']) &&
-            strpos($_SERVER['HTTP_USER_AGENT'], 'Mac') !== false ? self::_('Cmd') : self::_('Ctrl');
+        return array_key_exists('HTTP_USER_AGENT', $_SERVER) &&
+            str_contains($_SERVER['HTTP_USER_AGENT'], 'Mac') ? self::_('Cmd') : self::_('Ctrl');
     }
 
     /**
@@ -364,16 +428,18 @@ class I18n
             case 'oc':
             case 'tr':
             case 'zh':
+            case 'zh-tw':
                 return $n > 1 ? 1 : 0;
             case 'he':
                 return $n === 1 ? 0 : ($n === 2 ? 1 : (($n < 0 || $n > 10) && ($n % 10 === 0) ? 2 : 3));
             case 'id':
             case 'ja':
             case 'jbo':
+            case 'ko':
             case 'th':
                 return 0;
             case 'lt':
-                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : (($n % 10 >= 2 && $n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
+                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : (($n % 10 >= 2 && ($n % 100 < 10 || $n % 100 >= 20)) ? 1 : 2);
             case 'pl':
                 return $n === 1 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
             case 'ro':
@@ -403,18 +469,18 @@ class I18n
     protected static function _getMatchingLanguage($acceptedLanguages, $availableLanguages)
     {
         $matches = [];
-        $any     = false;
+        $any = false;
         foreach ($acceptedLanguages as $acceptedQuality => $acceptedValues) {
             $acceptedQuality = floatval($acceptedQuality);
             if ($acceptedQuality === 0.0) {
                 continue;
             }
-            foreach ($availableLanguages as $availableValue) {
-                $availableQuality = 1.0;
-                foreach ($acceptedValues as $acceptedValue) {
-                    if ($acceptedValue === '*') {
-                        $any = true;
-                    }
+            foreach ($acceptedValues as $acceptedValue) {
+                if ($acceptedValue === '*') {
+                    $any = true;
+                }
+                foreach ($availableLanguages as $availableValue) {
+                    $availableQuality = 1.0;
                     $matchingGrade = self::_matchLanguage($acceptedValue, $availableValue);
                     if ($matchingGrade > 0) {
                         $q = (string) ($acceptedQuality * $availableQuality * $matchingGrade);

+ 11 - 3
lib/Proxy/AbstractProxy.php

@@ -30,6 +30,14 @@ abstract class AbstractProxy
      */
     private $_error = '';
 
+    /**
+     * proxy URL
+     *
+     * @access protected
+     * @var    string
+     */
+    protected $_proxyUrl = '';
+
     /**
      * shortened URL
      *
@@ -61,15 +69,15 @@ abstract class AbstractProxy
             return;
         }
 
-        $proxyUrl = $this->_getProxyUrl($conf);
+        $this->_proxyUrl = $this->_getProxyUrl($conf);
 
-        if (empty($proxyUrl)) {
+        if (empty($this->_proxyUrl)) {
             $this->_error = 'Proxy error: Proxy URL is empty. This can be a configuration issue, like wrong or missing config keys.';
             $this->logErrorWithClassName($this->_error);
             return;
         }
 
-        $data = file_get_contents($proxyUrl, false,
+        $data = file_get_contents($this->_proxyUrl, false,
             stream_context_create(
                 [
                     'http' => $this->_getProxyPayload($conf, $link),

+ 90 - 0
lib/Proxy/ChhotoProxy.php

@@ -0,0 +1,90 @@
+<?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\Proxy;
+
+use JsonException;
+use PrivateBin\Configuration;
+use PrivateBin\Json;
+
+/**
+ * ChhotoProxy
+ *
+ * Forwards a URL for shortening to Chhoto URL and stores the result.
+ */
+class ChhotoProxy extends AbstractProxy
+{
+    /**
+     * Overrides the abstract parent function to get the proxy URL.
+     *
+     * @param Configuration $conf
+     * @return string
+     */
+    protected function _getProxyUrl(Configuration $conf): string
+    {
+        return $conf->getKey('apiurl', 'chhoto');
+    }
+
+    /**
+     * Overrides the abstract parent function to get contents from Chhoto API.
+     *
+     * @access protected
+     * @param Configuration $conf
+     * @param string $link
+     * @return array
+     */
+    protected function _getProxyPayload(Configuration $conf, string $link): array
+    {
+        $apiKey = $conf->getKey('apikey', 'chhoto');
+
+        $body = [
+            'shortlink'    => '',          // empty = auto-generate
+            'longlink'     => $link,
+            'expiry_delay' => 0,           // 0 = never expire
+            'notes'        => 'PrivateBin paste',
+        ];
+
+        try {
+            return [
+                'method'  => 'POST',
+                'header'  => "Content-Type: application/json\r\n" .
+                             'X-API-Key: ' . $apiKey . "\r\n" .
+                             "Accept: application/json\r\n",
+                'content' => Json::encode($body),
+            ];
+        } catch (JsonException $e) {
+            error_log('[' . get_class($this) . '] Error encoding body: ' . $e->getMessage());
+            return [];
+        }
+    }
+
+    /**
+     * Extracts the short URL from the Chhoto API response.
+     *
+     * @access protected
+     * @param array $data
+     * @return ?string
+     */
+    protected function _extractShortUrl(array $data): ?string
+    {
+        // Chhoto usually returns "shorturl"
+        if (!empty($data['shorturl'])) {
+            return $data['shorturl'];
+        }
+
+        // Fallback for older versions that return only the slug
+        if (!empty($data['shortlink'])) {
+            return $this->_proxyUrl . ltrim($data['shortlink'], '/');
+        }
+
+        return null;
+    }
+}

+ 27 - 6
lib/Request.php

@@ -130,6 +130,7 @@ class Request
                     'pasteid'          => FILTER_SANITIZE_SPECIAL_CHARS,
                     'shortenviayourls' => FILTER_SANITIZE_SPECIAL_CHARS,
                     'shortenviashlink' => FILTER_SANITIZE_SPECIAL_CHARS,
+                    'shortenviachhoto' => FILTER_SANITIZE_SPECIAL_CHARS,
                 ], false);
         }
         if (
@@ -143,21 +144,40 @@ class Request
         }
 
         // prepare operation, depending on current parameters
-        if (array_key_exists('pasteid', $this->_params) && !empty($this->_params['pasteid'])) {
-            if (array_key_exists('deletetoken', $this->_params) && !empty($this->_params['deletetoken'])) {
+        if (
+            array_key_exists('pasteid', $this->_params) &&
+            is_string($this->_params['pasteid']) &&
+            !empty($this->_params['pasteid'])
+        ) {
+            if (
+                array_key_exists('deletetoken', $this->_params) &&
+                is_string($this->_params['deletetoken']) &&
+                !empty($this->_params['deletetoken'])
+            ) {
                 $this->_operation = 'delete';
             } elseif ($this->_operation !== 'create') {
                 $this->_operation = 'read';
             }
-        } elseif (array_key_exists('jsonld', $this->_params) && !empty($this->_params['jsonld'])) {
+        } elseif (
+            array_key_exists('jsonld', $this->_params) &&
+            is_string($this->_params['jsonld']) &&
+            !empty($this->_params['jsonld'])
+        ) {
             $this->_operation = 'jsonld';
-        } elseif (array_key_exists('link', $this->_params) && !empty($this->_params['link'])) {
+        } elseif (
+            array_key_exists('link', $this->_params) &&
+            is_string($this->_params['link']) &&
+            !empty($this->_params['link'])
+        ) {
             if (str_contains($this->getRequestUri(), '/shortenviayourls') || array_key_exists('shortenviayourls', $this->_params)) {
                 $this->_operation = 'yourlsproxy';
             }
             if (str_contains($this->getRequestUri(), '/shortenviashlink') || array_key_exists('shortenviashlink', $this->_params)) {
                 $this->_operation = 'shlinkproxy';
             }
+            if (str_contains($this->getRequestUri(), '/shortenviachhoto') || array_key_exists('shortenviachhoto', $this->_params)) {
+                $this->_operation = 'chhotoproxy';
+            }
         }
     }
 
@@ -230,8 +250,9 @@ class Request
      */
     public function getRequestUri()
     {
-        $uri = array_key_exists('REQUEST_URI', $_SERVER) ? filter_var($_SERVER['REQUEST_URI'], FILTER_SANITIZE_URL) : '';
-        return empty($uri) ? '/' : parse_url($uri, PHP_URL_PATH);
+        $uri  = array_key_exists('REQUEST_URI', $_SERVER) ? filter_var($_SERVER['REQUEST_URI'], FILTER_SANITIZE_URL) : '';
+        $path = empty($uri) ? '' : parse_url($uri, PHP_URL_PATH);
+        return is_string($path) && $path !== '' ? $path : '/';
     }
 
     /**

+ 4 - 1
lib/TemplateSwitcher.php

@@ -73,7 +73,10 @@ class TemplateSwitcher
      */
     public static function getTemplate(): string
     {
-        if (array_key_exists('template', $_COOKIE)) {
+        if (
+            array_key_exists('template', $_COOKIE) &&
+            is_string($_COOKIE['template'])
+        ) {
             $template = basename($_COOKIE['template']);
             if (self::isTemplateAvailable($template)) {
                 return $template;

+ 5 - 5
tpl/bootstrap5.php

@@ -49,7 +49,7 @@ if ($MARKDOWN) :
 <?php
 endif;
 ?>
-		<?php $this->_scriptTag('js/purify-3.4.12.js', 'defer'); ?>
+		<?php $this->_scriptTag('js/purify-3.4.17.js', 'defer'); ?>
 		<?php $this->_scriptTag('js/legacy.js', 'defer'); ?>
 		<?php $this->_scriptTag('js/privatebin.js', 'defer'); ?>
 		<!-- icon -->
@@ -158,12 +158,12 @@ endif;
 					<span class="navbar-toggler-icon"></span>
 				</button>
 				<div id="navbar" class="collapse navbar-collapse">
-					<ul class="navbar-nav me-auto gap-2 align-items-lg-center align-items-stretch">
+					<ul class="navbar-nav me-auto gap-2 align-items-lg-center align-items-stretch flex-wrap">
 						<li id="loadingindicator" class="navbar-text hidden me-auto">
 							<svg width="16" height="16" fill="currentColor" aria-hidden="true"><use href="img/bootstrap-icons.svg#clock" /></svg>
 							<?php echo I18n::_('Loading…'), PHP_EOL; ?>
 						</li>
-						<li class="nav-item d-flex flex-lg-row flex-column">
+						<li class="nav-item d-flex flex-lg-row flex-column hidden">
 							<button id="retrybutton" type="button" class="reloadlink hidden btn btn-primary d-flex justify-content-center align-items-center gap-1">
 								<svg width="16" height="16" fill="currentColor" aria-hidden="true"><use href="img/bootstrap-icons.svg#repeat" /></svg> <?php echo I18n::_('Retry'), PHP_EOL; ?>
 							</button>
@@ -449,7 +449,7 @@ if (!empty($URLSHORTENER)) :
 								<?php if ($SHORTENBYDEFAULT) : ?>
 								data-autoshorten="true"
 								<?php endif; ?>
-								type="button" class="btn btn-primary btn-block d-flex justify-content-center align-items-center gap-1"
+								type="button" class="btn btn-primary btn-block d-flex justify-content-center align-items-center gap-1<?php echo $SHORTENBYDEFAULT ? ' hidden' : ''; ?>"
 						>
 							<svg width="16" height="16" fill="currentColor" aria-hidden="true"><use href="img/bootstrap-icons.svg#send" /></svg> <?php echo I18n::_('Shorten URL'), PHP_EOL; ?>
 						</button>
@@ -469,7 +469,7 @@ endif;
 					<li role="presentation" class="nav-item me-1"><a class="nav-link active" role="tab" id="messageedit" href="#"><?php echo I18n::_('Editor'); ?></a></li>
 					<li role="presentation" class="nav-item me-1"><a class="nav-link" role="tab" id="messagepreview" href="#"><?php echo I18n::_('Preview'); ?></a></li>
 					<li role="presentation" class="nav-item ms-auto">
-						<button id="sendbutton" type="button" tabindex="2" class="hidden btn btn-primary d-flex justify-content-center align-items-center gap-1">
+						<button id="sendbutton" type="button" tabindex="2" class="hidden btn btn-primary d-flex justify-content-center align-items-center gap-1" title="<?php echo I18n::_('Hit %s+s to create the document, storing it on %s', I18n::getCopyHotkey(), I18n::_($NAME)); ?>" aria-keyshortcuts="Control+S Meta+S">
 							<svg width="16" height="16" fill="currentColor" aria-hidden="true"><use href="img/bootstrap-icons.svg#cloud-upload" /></svg> <?php echo I18n::_('Create'), PHP_EOL; ?>
 						</button>
 					</li>

+ 52 - 0
tst/AdministrationEmptyDirsTest.php

@@ -0,0 +1,52 @@
+<?php declare(strict_types=1);
+use PHPUnit\Framework\TestCase;
+
+class AdministrationEmptyDirsTest extends TestCase
+{
+    private $_path;
+
+    public function setUp(): void
+    {
+        $this->_path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin administration';
+        Helper::rmDir($this->_path);
+        mkdir($this->_path);
+        mkdir($this->_path . DIRECTORY_SEPARATOR . 'cfg');
+
+        $dataPath = $this->_path . DIRECTORY_SEPARATOR . 'data with spaces';
+        mkdir($dataPath);
+        mkdir($dataPath . DIRECTORY_SEPARATOR . 'empty' . DIRECTORY_SEPARATOR . 'nested', 0777, true);
+        file_put_contents($dataPath . DIRECTORY_SEPARATOR . 'keep.txt', 'keep');
+
+        $options                         = parse_ini_file(CONF_SAMPLE, true);
+        $options['model_options']['dir'] = $dataPath;
+        Helper::createIniFile(
+            $this->_path . DIRECTORY_SEPARATOR . 'cfg' . DIRECTORY_SEPARATOR . 'conf.php',
+            $options
+        );
+    }
+
+    public function tearDown(): void
+    {
+        Helper::rmDir($this->_path);
+    }
+
+    public function testEmptyDirsSupportsStoragePathsWithSpaces()
+    {
+        $command = 'CONFIG_PATH=' .
+            escapeshellarg($this->_path . DIRECTORY_SEPARATOR . 'cfg') . ' ' .
+            escapeshellarg(PHP_BINARY) . ' ' .
+            escapeshellarg(realpath(PATH . 'bin' . DIRECTORY_SEPARATOR . 'administration')) .
+            ' --empty-dirs 2>&1';
+        exec($command, $output, $exitCode);
+
+        $this->assertSame(0, $exitCode, implode(PHP_EOL, $output));
+        $this->assertDirectoryDoesNotExist(
+            $this->_path . DIRECTORY_SEPARATOR . 'data with spaces' .
+            DIRECTORY_SEPARATOR . 'empty'
+        );
+        $this->assertFileExists(
+            $this->_path . DIRECTORY_SEPARATOR . 'data with spaces' .
+            DIRECTORY_SEPARATOR . 'keep.txt'
+        );
+    }
+}

+ 42 - 0
tst/AdministrationOptionsTest.php

@@ -0,0 +1,42 @@
+<?php declare(strict_types=1);
+use PHPUnit\Framework\TestCase;
+
+class AdministrationOptionsTest extends TestCase
+{
+    public function testAcceptsSingleAction()
+    {
+        [$exitCode, $output] = $this->runAdministration('--help');
+
+        $this->assertSame(0, $exitCode, $output);
+        $this->assertStringContainsString('Usage:', $output);
+    }
+
+    /**
+     * @dataProvider invalidArgumentsProvider
+     */
+    public function testRejectsAmbiguousArguments($arguments)
+    {
+        [$exitCode, $output] = $this->runAdministration($arguments);
+
+        $this->assertSame(3, $exitCode, $output);
+        $this->assertStringContainsString('Error: unsupported arguments given', $output);
+    }
+
+    private function runAdministration($arguments)
+    {
+        $command = escapeshellarg(PHP_BINARY) . ' ' .
+            escapeshellarg(realpath(PATH . 'bin' . DIRECTORY_SEPARATOR . 'administration')) .
+            ' ' . $arguments . ' 2>&1';
+        exec($command, $output, $exitCode);
+
+        return [$exitCode, implode(PHP_EOL, $output)];
+    }
+
+    public function invalidArgumentsProvider()
+    {
+        return [
+            'trailing positional argument' => ['--help unexpected'],
+            'multiple actions'             => ['--help --delete-all'],
+        ];
+    }
+}

+ 22 - 0
tst/ControllerTest.php

@@ -115,6 +115,28 @@ class ControllerTest extends TestCase
         );
     }
 
+    /**
+     * @runInSeparateProcess
+     */
+    public function testViewForceRegionalLanguageDefault()
+    {
+        $options                              = parse_ini_file(CONF, true);
+        $options['main']['languageselection'] = false;
+        $options['main']['languagedefault']   = 'zh-tw';
+        Helper::createIniFile(CONF, $options);
+        $_COOKIE['lang'] = 'de';
+        ob_start();
+        new Controller;
+        $content = ob_get_contents();
+        ob_end_clean();
+        $this->assertStringContainsString(
+            '<title>PrivateBin</title>',
+            $content,
+            'outputs title correctly'
+        );
+        $this->assertSame('zh-tw', $_COOKIE['lang'], 'forces configured default language');
+    }
+
     /**
      * @runInSeparateProcess
      */

+ 29 - 0
tst/Data/DatabaseTest.php

@@ -109,6 +109,35 @@ class DatabaseTest extends TestCase
         $this->assertEquals($original, $this->_model->read(Helper::getPasteId()));
     }
 
+    public function testCommentsWithSameTimestampRemainInInsertionOrder()
+    {
+        $pasteid = Helper::getPasteId();
+        $paste   = Helper::getPaste();
+        $this->_model->delete($pasteid);
+        $this->assertTrue($this->_model->create($pasteid, $paste));
+
+        $expectedIds = [];
+        for ($i = 1; $i <= 12; ++$i) {
+            $comment                    = Helper::getComment();
+            $comment['meta']['created'] = 1735689600;
+            $commentid                  = sprintf('%016x', $i);
+            $expectedIds[]              = $commentid;
+            $this->assertTrue(
+                $this->_model->createComment($pasteid, $pasteid, $commentid, $comment)
+            );
+        }
+
+        $actualIds = array_values(
+            array_map(
+                function ($comment) {
+                    return $comment['id'];
+                },
+                $this->_model->readComments($pasteid)
+            )
+        );
+        $this->assertSame($expectedIds, $actualIds);
+    }
+
     /**
      * pastes a-g are expired and should get deleted, x never expires and y-z expire in an hour
      */

+ 51 - 1
tst/Data/FilesystemTest.php

@@ -75,6 +75,41 @@ class FilesystemTest extends TestCase
         $this->assertEquals($original, $this->_model->read(Helper::getPasteId()));
     }
 
+    public function testCorruptCommentsAreIgnored()
+    {
+        $pasteid   = Helper::getPasteId();
+        $commentid = Helper::getCommentId();
+        $comment   = Helper::getComment();
+        $this->assertTrue($this->_model->createComment($pasteid, $pasteid, $commentid, $comment));
+
+        $discussionPath = $this->_path . DIRECTORY_SEPARATOR . substr($pasteid, 0, 2) .
+            DIRECTORY_SEPARATOR . substr($pasteid, 2, 2) . DIRECTORY_SEPARATOR .
+            $pasteid . '.discussion' . DIRECTORY_SEPARATOR;
+        file_put_contents(
+            $discussionPath . $pasteid . '.ffffffffffffffff.' . $pasteid . '.php',
+            Filesystem::PROTECTION_LINE . PHP_EOL . '{'
+        );
+        file_put_contents(
+            $discussionPath . $pasteid . '.invalid.invalid.php',
+            Filesystem::PROTECTION_LINE . PHP_EOL . json_encode($comment)
+        );
+        file_put_contents(
+            $discussionPath . 'ffffffffffffffff.eeeeeeeeeeeeeeee.' . $pasteid . '.php',
+            Filesystem::PROTECTION_LINE . PHP_EOL . json_encode($comment)
+        );
+        file_put_contents(
+            $discussionPath . $pasteid . '.dddddddddddddddd.' . $pasteid,
+            Filesystem::PROTECTION_LINE . PHP_EOL . json_encode($comment)
+        );
+
+        $errorLog = ini_get('error_log');
+        ini_set('error_log', '/dev/null');
+        $comments = $this->_model->readComments($pasteid);
+        ini_set('error_log', $errorLog);
+        $this->assertCount(1, $comments);
+        $this->assertSame($commentid, current($comments)['id']);
+    }
+
     /**
      * pastes a-g are expired and should get deleted, x never expires and y-z expire in an hour
      */
@@ -163,16 +198,31 @@ class FilesystemTest extends TestCase
             file_put_contents($storagedir . $dataid . '.' . $commentid . '.' . $dataid, json_encode($comment));
         }
         // check that all 10 pastes were converted after the purge
-        $this->_model->purge(10);
+        $oldUmask = umask(0000);
+        try {
+            $this->_model->purge(10);
+        } finally {
+            umask($oldUmask);
+        }
         foreach ($ids as $dataid => $storagedir) {
             $dataid = (string) $dataid; // undue potential key cast, see https://www.php.net/manual/en/language.types.array.php
             $this->assertFileExists($storagedir . $dataid . '.php', "paste $dataid exists in new format");
+            $this->assertSame(
+                0640,
+                fileperms($storagedir . $dataid . '.php') & 0777,
+                "converted paste $dataid has protected permissions"
+            );
             $this->assertFileDoesNotExist($storagedir . $dataid, "old format paste $dataid got removed");
             $this->assertTrue($this->_model->exists($dataid), "paste $dataid exists");
             $this->assertEquals($this->_model->read($dataid), $paste, "paste $dataid wasn't modified in the conversion");
 
             $storagedir .= $dataid . '.discussion' . DIRECTORY_SEPARATOR;
             $this->assertFileExists($storagedir . $dataid . '.' . $commentid . '.' . $dataid . '.php', "comment of $dataid exists in new format");
+            $this->assertSame(
+                0640,
+                fileperms($storagedir . $dataid . '.' . $commentid . '.' . $dataid . '.php') & 0777,
+                "converted comment of $dataid has protected permissions"
+            );
             $this->assertFileDoesNotExist($storagedir . $dataid . '.' . $commentid . '.' . $dataid, "old format comment of $dataid got removed");
             $this->assertTrue($this->_model->existsComment($dataid, $dataid, $commentid), "comment in paste $dataid exists");
             $comment             = $comment;

+ 96 - 16
tst/I18nTest.php

@@ -44,7 +44,7 @@ class I18nTest extends TestCase
     public function testTranslationFallback()
     {
         $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'foobar';
-        $messageId                       = 'It does not matter if the message ID exists';
+        $messageId = 'It does not matter if the message ID exists';
         I18n::loadTranslations();
         $this->assertEquals($messageId, I18n::_($messageId), 'fallback to en');
         I18n::getLanguageLabels();
@@ -80,6 +80,43 @@ class I18nTest extends TestCase
         $this->assertEquals('2 heures', I18n::_('%d hours', 2), '2 hours in French');
     }
 
+    public function testEqualQualityPreservesBrowserOrder()
+    {
+        $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'fr;q=0.8,de;q=0.8';
+        I18n::loadTranslations();
+
+        $this->assertEquals('fr', I18n::getLanguage());
+    }
+
+    public function testBulgarianMonthExpirationTranslation()
+    {
+        $_COOKIE['lang'] = 'bg';
+        I18n::loadTranslations();
+
+        $this->assertEquals(
+            'Този документ изтича след един месец.',
+            I18n::_('This document will expire in %d months.', 1)
+        );
+        $this->assertEquals(
+            'Този документ изтича след 2 месеца.',
+            I18n::_('This document will expire in %d months.', 2)
+        );
+    }
+
+    public function testBrowserLanguageLtDetection()
+    {
+        $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'lt;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
+        I18n::loadTranslations();
+        $this->assertEquals('lt', I18n::getLanguage(), 'browser language lt');
+        $this->assertEquals('1 sekundę', I18n::_('%d seconds', 1), '1 second in Lithuanian');
+        $this->assertEquals('2 sekundės', I18n::_('%d seconds', 2), '2 seconds in Lithuanian');
+        $this->assertEquals('10 sekundžių', I18n::_('%d seconds', 10), '10 seconds in Lithuanian');
+        $this->assertEquals('22 sekundės', I18n::_('%d seconds', 22), '22 seconds in Lithuanian');
+        $this->assertEquals('110 sekundžių', I18n::_('%d seconds', 110), '110 seconds in Lithuanian');
+        $this->assertEquals('111 sekundžių', I18n::_('%d seconds', 111), '111 seconds in Lithuanian');
+        $this->assertEquals('120 sekundžių', I18n::_('%d seconds', 120), '120 seconds in Lithuanian');
+    }
+
     public function testBrowserLanguageNoDetection()
     {
         $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'no;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
@@ -110,6 +147,27 @@ class I18nTest extends TestCase
         $this->assertEquals('2 小时', I18n::_('%d hours', 2), '2 hours in Chinese');
     }
 
+    public function testBrowserLanguageZhHantDetection()
+    {
+        foreach ([
+            'zh-Hant,zh;q=0.8,en;q=0.2'            => 'zh-tw',
+            'zh-Hant-TW,zh;q=0.8,en;q=0.2'         => 'zh-tw',
+            'zh-TW,en;q=0.2'                       => 'zh-tw',
+            'zh-TW-u-nu-hanidec,en;q=0.2'          => 'zh-tw',
+            'zh-HK,zh;q=0.8,en;q=0.2'              => 'zh-tw',
+            'zh-MO,zh;q=0.8,en;q=0.2'              => 'zh-tw',
+            'zh-Hant,zh-Hans;q=0.8,en;q=0.2'       => 'zh-tw',
+            'zh-CN,zh-Hant;q=0.8,en;q=0.2'         => 'zh',
+            'zh-Hans,zh-Hant;q=0.8,en;q=0.2'       => 'zh',
+            'zh-Hans-CN,en;q=0.2'                  => 'zh',
+            'zh;q=0.9,zh-Hant;q=0.8,en;q=0.2'      => 'zh',
+        ] as $acceptedLanguage => $language) {
+            $_SERVER['HTTP_ACCEPT_LANGUAGE'] = $acceptedLanguage;
+            I18n::loadTranslations();
+            $this->assertEquals($language, I18n::getLanguage(), 'browser language ' . $acceptedLanguage);
+        }
+    }
+
     public function testBrowserLanguagePlDetection()
     {
         $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'pl;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
@@ -141,14 +199,14 @@ class I18nTest extends TestCase
         $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'sl;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
         I18n::loadTranslations();
         $this->assertEquals('sl', I18n::getLanguage(), 'browser language sl');
-        $this->assertEquals('0 ura',  I18n::_('%d hours', 0), '0 hours in Slovene');
-        $this->assertEquals('1 uri',  I18n::_('%d hours', 1), '1 hour in Slovene');
-        $this->assertEquals('2 ure', I18n::_('%d hours', 2), '2 hours in Slovene');
-        $this->assertEquals('3 ur',  I18n::_('%d hours', 3), '3 hours in Slovene');
-        $this->assertEquals('11 ura',  I18n::_('%d hours', 11), '11 hours in Slovene');
-        $this->assertEquals('101 uri',  I18n::_('%d hours', 101), '101 hours in Slovene');
-        $this->assertEquals('102 ure', I18n::_('%d hours', 102), '102 hours in Slovene');
-        $this->assertEquals('104 ur',  I18n::_('%d hours', 104), '104 hours in Slovene');
+        $this->assertEquals('0 ur',  I18n::_('%d hours', 0), '0 hours in Slovene');
+        $this->assertEquals('1 ura',  I18n::_('%d hours', 1), '1 hour in Slovene');
+        $this->assertEquals('2 uri', I18n::_('%d hours', 2), '2 hours in Slovene');
+        $this->assertEquals('3 ure',  I18n::_('%d hours', 3), '3 hours in Slovene');
+        $this->assertEquals('11 ur',  I18n::_('%d hours', 11), '11 hours in Slovene');
+        $this->assertEquals('101 ura',  I18n::_('%d hours', 101), '101 hours in Slovene');
+        $this->assertEquals('102 uri', I18n::_('%d hours', 102), '102 hours in Slovene');
+        $this->assertEquals('104 ure',  I18n::_('%d hours', 104), '104 hours in Slovene');
     }
 
     public function testBrowserLanguageCsDetection()
@@ -162,6 +220,12 @@ class I18nTest extends TestCase
         $this->assertEquals('14 minut',  I18n::_('%d minutes', 14), '14 minutes in Czech');
     }
 
+    public function testInvalidBrowserLanguageQualityIsIgnored()
+    {
+        $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'de;q=2,en;q=0.8';
+        $this->assertSame(['0.8' => ['en']], I18n::getBrowserLanguages());
+    }
+
     public function testBrowserLanguageAnyDetection()
     {
         $_SERVER['HTTP_ACCEPT_LANGUAGE'] = '*';
@@ -180,7 +244,7 @@ class I18nTest extends TestCase
     {
         $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'foobar';
         I18n::loadTranslations();
-        $input  = '&<>"\'/`=';
+        $input = '&<>"\'/`=';
         $result = htmlspecialchars($input, ENT_QUOTES | ENT_HTML5 | ENT_DISALLOWED, 'UTF-8', false);
         $this->assertEquals($result, I18n::encode($input), 'encodes HTML entities');
         $this->assertEquals('<a>some ' . $result . ' + 1</a>', I18n::_('<a>some %s + %d</a>', $input, 1), 'encodes parameters in translations');
@@ -195,8 +259,8 @@ class I18nTest extends TestCase
         // For example, the French translation should not have the apostrophe encoded
         // See https://github.com/PrivateBin/PrivateBin/issues/1712
         $message = I18n::_('Document does not exist, has expired or has been deleted.');
-        $this->assertFalse(strpos($message, '&apos;') !== false, 'French apostrophe should not be encoded in translation message');
-        $this->assertTrue(strpos($message, "n'existe") !== false, 'French apostrophe should be present as literal character');
+        $this->assertFalse(str_contains($message, '&apos;'), 'French apostrophe should not be encoded in translation message');
+        $this->assertTrue(str_contains($message, "n'existe"), 'French apostrophe should be present as literal character');
     }
 
     public function testFallbackAlwaysPresent()
@@ -209,6 +273,7 @@ class I18nTest extends TestCase
         $languageIterator = new AppendIterator();
         $languageIterator->append(new GlobIterator(I18nMock::getPath('??.json')));
         $languageIterator->append(new GlobIterator(I18nMock::getPath('???.json'))); // for jbo
+        $languageIterator->append(new GlobIterator(I18nMock::getPath('??-??.json'))); // for regional variants like zh-tw
         $languageCount = 0;
         foreach ($languageIterator as $file) {
             ++$languageCount;
@@ -246,22 +311,37 @@ class I18nTest extends TestCase
         $this->assertEquals('Ctrl', I18n::getCopyHotkey(), 'returns Ctrl when user agent absent');
     }
 
+    public function testRightToLeftLanguages()
+    {
+        foreach (['ar', 'fa', 'he'] as $language) {
+            $_COOKIE['lang'] = $language;
+            I18n::loadTranslations();
+            $this->assertTrue(I18n::isRtl(), "$language is right-to-left");
+        }
+
+        $_COOKIE['lang'] = 'en';
+        I18n::loadTranslations();
+        $this->assertFalse(I18n::isRtl(), 'English is left-to-right');
+    }
+
     public function testMessageIdsExistInAllLanguages()
     {
         $messageIds = [];
-        $languages  = [];
+        $languages = [];
         foreach (new DirectoryIterator(PATH . 'i18n') as $file) {
             $fileNameLength = strlen($file->getFilename());
             if ($fileNameLength === 7) {       // xx.json
                 $language = substr($file->getFilename(), 0, 2);
             } elseif ($fileNameLength === 8) { // jbo.json
                 $language = substr($file->getFilename(), 0, 3);
+            } elseif ($fileNameLength === 10) { // xx-xx.json
+                $language = substr($file->getFilename(), 0, 5);
             } else {
                 continue;
             }
-            $languageJson         = file_get_contents($file->getPathname());
-            $languageMessageIds   = array_keys(Json::decode($languageJson));
-            $messageIds           = array_unique(array_merge($messageIds, $languageMessageIds));
+            $languageJson = file_get_contents($file->getPathname());
+            $languageMessageIds = array_keys(Json::decode($languageJson));
+            $messageIds = array_unique(array_merge($messageIds, $languageMessageIds));
             $languages[$language] = $languageMessageIds;
         }
         foreach ($messageIds as $messageId) {

+ 23 - 5
tst/JsonApiTest.php

@@ -199,7 +199,7 @@ class JsonApiTest extends TestCase
         ob_end_clean();
         $this->assertEquals(str_replace(
             '?jsonld=',
-            '/?jsonld=',
+            '\/?jsonld=',
             file_get_contents(PUBLIC_PATH . '/js/paste.jsonld')
         ), $content, 'outputs data correctly');
     }
@@ -216,7 +216,7 @@ class JsonApiTest extends TestCase
         ob_end_clean();
         $this->assertEquals(str_replace(
             '?jsonld=',
-            '/?jsonld=',
+            '\/?jsonld=',
             file_get_contents(PUBLIC_PATH . '/js/comment.jsonld')
         ), $content, 'outputs data correctly');
     }
@@ -233,7 +233,7 @@ class JsonApiTest extends TestCase
         ob_end_clean();
         $this->assertEquals(str_replace(
             '?jsonld=',
-            '/?jsonld=',
+            '\/?jsonld=',
             file_get_contents(PUBLIC_PATH . '/js/pastemeta.jsonld')
         ), $content, 'outputs data correctly');
     }
@@ -250,7 +250,7 @@ class JsonApiTest extends TestCase
         ob_end_clean();
         $this->assertEquals(str_replace(
             '?jsonld=',
-            '/?jsonld=',
+            '\/?jsonld=',
             file_get_contents(PUBLIC_PATH . '/js/commentmeta.jsonld')
         ), $content, 'outputs data correctly');
     }
@@ -267,11 +267,29 @@ class JsonApiTest extends TestCase
         ob_end_clean();
         $this->assertEquals(str_replace(
             '?jsonld=',
-            '/?jsonld=',
+            '\/?jsonld=',
             file_get_contents(PUBLIC_PATH . '/js/types.jsonld')
         ), $content, 'outputs data correctly');
     }
 
+    /**
+     * @runInSeparateProcess
+     */
+    public function testJsonLdPathBypass()
+    {
+        $_SERVER['REQUEST_URI'] = '/","bypass\\';
+        $_GET['jsonld']         = 'paste';
+        ob_start();
+        new Controller;
+        $content = ob_get_contents();
+        ob_end_clean();
+        $this->assertEquals(str_replace(
+            '?jsonld=',
+            '\/\",\"bypass\\\\?jsonld=',
+            file_get_contents(PUBLIC_PATH . '/js/paste.jsonld')
+        ), $content, 'outputs data correctly');
+    }
+
     /**
      * @runInSeparateProcess
      */

+ 86 - 0
tst/MigrateForceOverwriteTest.php

@@ -0,0 +1,86 @@
+<?php declare(strict_types=1);
+use PHPUnit\Framework\TestCase;
+use PrivateBin\Data\Filesystem;
+
+class MigrateForceOverwriteTest extends TestCase
+{
+    private $_destination;
+
+    private $_path;
+
+    private $_source;
+
+    public function setUp(): void
+    {
+        $this->_path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_migrate_force';
+        Helper::rmDir($this->_path);
+        mkdir($this->_path);
+        mkdir($this->_path . DIRECTORY_SEPARATOR . 'source_cfg');
+        mkdir($this->_path . DIRECTORY_SEPARATOR . 'destination_cfg');
+
+        $options                         = parse_ini_file(CONF_SAMPLE, true);
+        $options['model_options']['dir'] = $this->_path . DIRECTORY_SEPARATOR . 'source';
+        $this->_source                   = new Filesystem($options['model_options']);
+        Helper::createIniFile(
+            $this->_path . DIRECTORY_SEPARATOR . 'source_cfg' . DIRECTORY_SEPARATOR . 'conf.php',
+            $options
+        );
+
+        $options['model_options']['dir'] = $this->_path . DIRECTORY_SEPARATOR . 'destination';
+        $this->_destination              = new Filesystem($options['model_options']);
+        Helper::createIniFile(
+            $this->_path . DIRECTORY_SEPARATOR . 'destination_cfg' . DIRECTORY_SEPARATOR . 'conf.php',
+            $options
+        );
+    }
+
+    public function tearDown(): void
+    {
+        Helper::rmDir($this->_path);
+    }
+
+    public function testForceReplacesPasteAndDiscussion()
+    {
+        $sourcePaste       = Helper::getPaste();
+        $sourcePaste['ct'] = 'source paste';
+        $this->_source->create(Helper::getPasteId(), $sourcePaste);
+        $sourceComment       = Helper::getComment();
+        $sourceComment['ct'] = 'source comment';
+        $this->_source->createComment(
+            Helper::getPasteId(),
+            Helper::getPasteId(),
+            Helper::getCommentId(),
+            $sourceComment
+        );
+
+        $destinationPaste       = Helper::getPaste();
+        $destinationPaste['ct'] = 'old paste';
+        $this->_destination->create(Helper::getPasteId(), $destinationPaste);
+        $destinationComment       = Helper::getComment();
+        $destinationComment['ct'] = 'old comment';
+        $this->_destination->createComment(
+            Helper::getPasteId(),
+            Helper::getPasteId(),
+            Helper::getCommentId(),
+            $destinationComment
+        );
+
+        $command = escapeshellarg(PHP_BINARY) . ' ' .
+            escapeshellarg(realpath(PATH . 'bin' . DIRECTORY_SEPARATOR . 'migrate')) .
+            ' -f --delete-after ' .
+            escapeshellarg($this->_path . DIRECTORY_SEPARATOR . 'source_cfg') . ' ' .
+            escapeshellarg($this->_path . DIRECTORY_SEPARATOR . 'destination_cfg') .
+            ' 2>&1';
+        exec($command, $output, $exitCode);
+
+        $this->assertSame(0, $exitCode, implode(PHP_EOL, $output));
+        $this->assertFalse($this->_source->exists(Helper::getPasteId()));
+        $this->assertSame(
+            'source paste',
+            $this->_destination->read(Helper::getPasteId())['ct']
+        );
+        $comments = array_values($this->_destination->readComments(Helper::getPasteId()));
+        $this->assertCount(1, $comments);
+        $this->assertSame('source comment', $comments[0]['ct']);
+    }
+}

+ 31 - 0
tst/MigrateTest.php

@@ -81,4 +81,35 @@ class MigrateTest extends TestCase
         $this->assertTrue($this->_model_1->exists(Helper::getPasteId()), 'paste migrated back');
         $this->assertTrue($this->_model_1->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment migrated back');
     }
+
+    public function testDamagedSourcePasteIsPreserved()
+    {
+        $this->_model_1->delete(Helper::getPasteId());
+        $paste = Helper::getPaste();
+        $this->_model_1->create(Helper::getPasteId(), $paste);
+        file_put_contents(
+            $this->_path_instance_1 . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR .
+            substr(Helper::getPasteId(), 0, 2) . DIRECTORY_SEPARATOR .
+            substr(Helper::getPasteId(), 2, 2) . DIRECTORY_SEPARATOR .
+            Helper::getPasteId() . '.php',
+            Filesystem::PROTECTION_LINE . PHP_EOL . '{'
+        );
+
+        $output    = null;
+        $exit_code = 0;
+        exec(
+            'php ' . PATH . 'bin' . DIRECTORY_SEPARATOR . 'migrate --delete-after ' .
+            $this->_path_instance_1 . DIRECTORY_SEPARATOR . 'cfg ' .
+            $this->_path_instance_2 . DIRECTORY_SEPARATOR . 'cfg 2>&1',
+            $output,
+            $exit_code
+        );
+
+        $this->assertSame(1, $exit_code, implode(PHP_EOL, $output));
+        $this->assertStringContainsString(
+            'ERROR: Unable to read document ID ' . Helper::getPasteId(),
+            implode(PHP_EOL, $output)
+        );
+        $this->assertTrue($this->_model_1->exists(Helper::getPasteId()));
+    }
 }

+ 115 - 0
tst/MigrateWriteFailureTest.php

@@ -0,0 +1,115 @@
+<?php declare(strict_types=1);
+use PHPUnit\Framework\TestCase;
+use PrivateBin\Data\Filesystem;
+
+class MigrateWriteFailureTest extends TestCase
+{
+    private $_path;
+
+    private $_destinationPath;
+
+    private $_source;
+
+    public function setUp(): void
+    {
+        $this->_path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_migrate_write_failure';
+        Helper::rmDir($this->_path);
+        mkdir($this->_path);
+        mkdir($this->_path . DIRECTORY_SEPARATOR . 'source_cfg');
+        mkdir($this->_path . DIRECTORY_SEPARATOR . 'destination_cfg');
+
+        $options                         = parse_ini_file(CONF_SAMPLE, true);
+        $options['model_options']['dir'] = $this->_path . DIRECTORY_SEPARATOR . 'source';
+        $this->_source                   = new Filesystem($options['model_options']);
+        Helper::createIniFile(
+            $this->_path . DIRECTORY_SEPARATOR . 'source_cfg' . DIRECTORY_SEPARATOR . 'conf.php',
+            $options
+        );
+
+        $this->_destinationPath = $this->_path . DIRECTORY_SEPARATOR . 'blocked';
+        file_put_contents($this->_destinationPath, 'not a directory');
+        $options['model_options']['dir'] = $this->_destinationPath;
+        Helper::createIniFile(
+            $this->_path . DIRECTORY_SEPARATOR . 'destination_cfg' . DIRECTORY_SEPARATOR . 'conf.php',
+            $options
+        );
+    }
+
+    public function tearDown(): void
+    {
+        Helper::rmDir($this->_path);
+    }
+
+    public function testSourceIsPreservedWhenDestinationWriteFails()
+    {
+        $paste = Helper::getPaste();
+        $this->_source->create(Helper::getPasteId(), $paste);
+
+        [$exitCode, $output] = $this->runMigration();
+
+        $this->assertSame(1, $exitCode, $output);
+        $this->assertStringContainsString(
+            'ERROR: Unable to save document ID ' . Helper::getPasteId(),
+            $output
+        );
+        $this->assertTrue($this->_source->exists(Helper::getPasteId()));
+    }
+
+    public function testSourceIsPreservedWhenDestinationCommentWriteFails()
+    {
+        unlink($this->_destinationPath);
+        mkdir(
+            $this->_destinationPath . DIRECTORY_SEPARATOR .
+            substr(Helper::getPasteId(), 0, 2) . DIRECTORY_SEPARATOR .
+            substr(Helper::getPasteId(), 2, 2),
+            0777,
+            true
+        );
+        file_put_contents(
+            $this->_destinationPath . DIRECTORY_SEPARATOR .
+            substr(Helper::getPasteId(), 0, 2) . DIRECTORY_SEPARATOR .
+            substr(Helper::getPasteId(), 2, 2) . DIRECTORY_SEPARATOR .
+            Helper::getPasteId() . '.discussion',
+            'not a directory'
+        );
+
+        $paste = Helper::getPaste();
+        $this->_source->create(Helper::getPasteId(), $paste);
+        $comment = Helper::getComment();
+        $this->_source->createComment(
+            Helper::getPasteId(),
+            Helper::getPasteId(),
+            Helper::getCommentId(),
+            $comment
+        );
+
+        [$exitCode, $output] = $this->runMigration();
+
+        $this->assertSame(1, $exitCode, $output);
+        $this->assertStringContainsString(
+            'ERROR: Unable to save document ID ' . Helper::getPasteId() .
+            ', parent id ' . Helper::getPasteId() .
+            ', comment id ' . Helper::getCommentId(),
+            $output
+        );
+        $this->assertTrue($this->_source->exists(Helper::getPasteId()));
+        $this->assertTrue($this->_source->existsComment(
+            Helper::getPasteId(),
+            Helper::getPasteId(),
+            Helper::getCommentId()
+        ));
+    }
+
+    private function runMigration()
+    {
+        $command = escapeshellarg(PHP_BINARY) . ' ' .
+            escapeshellarg(realpath(PATH . 'bin' . DIRECTORY_SEPARATOR . 'migrate')) .
+            ' --delete-after ' .
+            escapeshellarg($this->_path . DIRECTORY_SEPARATOR . 'source_cfg') . ' ' .
+            escapeshellarg($this->_path . DIRECTORY_SEPARATOR . 'destination_cfg') .
+            ' 2>&1';
+        exec($command, $output, $exitCode);
+
+        return [$exitCode, implode(PHP_EOL, $output)];
+    }
+}

+ 175 - 0
tst/Proxy/ChhotoProxyTest.php

@@ -0,0 +1,175 @@
+<?php declare(strict_types=1);
+
+use PHPUnit\Framework\TestCase;
+use PrivateBin\Configuration;
+use PrivateBin\Proxy\ChhotoProxy;
+
+class ChhotoProxyTest extends TestCase
+{
+    private $_conf;
+
+    private $_path;
+
+    private $_mock_chhoto_service;
+
+    public function setUp(): void
+    {
+        /* Setup Routine */
+        $this->_path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_data';
+        if (!is_dir($this->_path)) {
+            mkdir($this->_path);
+        }
+        $this->_mock_chhoto_service           = $this->_path . DIRECTORY_SEPARATOR . 'chhoto.json';
+        $options                              = parse_ini_file(CONF_SAMPLE, true);
+        $options['main']['basepath']          = 'https://example.com/';
+        $options['main']['urlshortener']      = 'https://example.com/shortenviachhoto?link=';
+        $options['chhoto']['apiurl']          = $this->_mock_chhoto_service;
+        $options['chhoto']['apikey']          = 'test_api_key';
+        Helper::confBackup();
+        Helper::createIniFile(CONF, $options);
+        $this->_conf = new Configuration;
+    }
+
+    public function tearDown(): void
+    {
+        /* Tear Down Routine */
+        unlink(CONF);
+        Helper::confRestore();
+        Helper::rmDir($this->_path);
+    }
+
+    public function testChhotoProxy()
+    {
+        // Chhoto usually returns the full short URL in the "shorturl" field.
+        file_put_contents($this->_mock_chhoto_service, '{"shorturl":"https:\/\/lix.sk\/abc123"}');
+
+        $chhoto = new ChhotoProxy($this->_conf, 'https://example.com/?foo#bar');
+        $this->assertFalse($chhoto->isError());
+        $this->assertEquals($chhoto->getUrl(), 'https://lix.sk/abc123');
+
+        // A URL that embeds a foreign host in the user-info part must still be
+        // accepted, because the host of the link is our own instance.
+        $chhoto = new ChhotoProxy($this->_conf, 'https://example.com/?@foreign.malicious.example?foo#bar');
+        $this->assertFalse($chhoto->isError());
+        $this->assertEquals($chhoto->getUrl(), 'https://lix.sk/abc123');
+    }
+
+    public function testChhotoProxyWithSlugFallback()
+    {
+        // Older Chhoto versions may return only the slug in "shortlink" instead
+        // of a full "shorturl". The proxy then prepends the configured apiurl.
+        file_put_contents($this->_mock_chhoto_service, '{"shortlink":"abc123"}');
+
+        $chhoto = new ChhotoProxy($this->_conf, 'https://example.com/?foo#bar');
+        $this->assertFalse($chhoto->isError());
+        // apiurl (the mock file path) is prepended to the ltrimmed slug.
+        $this->assertEquals($chhoto->getUrl(), $this->_mock_chhoto_service . 'abc123');
+    }
+
+    public function testChhotoProxyWithLeadingSlashSlug()
+    {
+        // A slug that already starts with a slash must not produce a double slash.
+        file_put_contents($this->_mock_chhoto_service, '{"shortlink":"/abc123"}');
+
+        $chhoto = new ChhotoProxy($this->_conf, 'https://example.com/?foo#bar');
+        $this->assertFalse($chhoto->isError());
+        $this->assertEquals($chhoto->getUrl(), $this->_mock_chhoto_service . 'abc123');
+    }
+
+    /**
+     * @dataProvider providerInvalidUrl
+     */
+    public function testInvalidUrl($url): void
+    {
+        $chhoto = new ChhotoProxy($this->_conf, $url);
+        $this->assertTrue($chhoto->isError());
+        $this->assertEquals($chhoto->getError(), 'Invalid URL given.');
+    }
+
+    public function providerInvalidUrl(): array
+    {
+        return [
+            [''],
+            [' '],
+            ['foo'],
+            ['https://'],
+            ['https://example.com'], // missing path and query parameter,
+            ['https://example.com/'], // missing query parameter
+            ['https://example.com?paste=something'], // missing path parameter
+            ['https://example.com@foreign.malicious.example?foo#bar'], // missing path parameter
+        ];
+    }
+
+    /**
+     * This tests for a trick using username of an URI, see:
+     * {@see https://cloud.google.com/blog/topics/threat-intelligence/url-obfuscation-schema-abuse/?hl=en}
+     *
+     * @dataProvider providerForeignUrlUsernameTrick
+     */
+    public function testForeignUrlUsingUsernameTrick($url): void
+    {
+        $chhoto = new ChhotoProxy($this->_conf, $url);
+        $this->assertTrue($chhoto->isError());
+        $this->assertEquals($chhoto->getError(), 'Trying to shorten a URL that isn\'t pointing at our instance.');
+    }
+
+    public function providerForeignUrlUsernameTrick(): array
+    {
+        return [
+            ['https://example.com@foreign.malicious.example/?foo#bar'],
+            ['https://example.com/@foreign.malicious.example?foo#bar'],
+        ];
+    }
+
+    /**
+     * @dataProvider providerForeignUrl
+     */
+    public function testForeignUrl($url): void
+    {
+        $chhoto = new ChhotoProxy($this->_conf, $url);
+        $this->assertTrue($chhoto->isError());
+        $this->assertEquals($chhoto->getError(), 'Trying to shorten a URL that isn\'t pointing at our instance.');
+    }
+
+    public function providerForeignUrl(): array
+    {
+        return [
+            ['ftp://example.com/?n=np'], // wrong protocol
+            ['https://other.example.com/?foo#bar'], // wrong domain
+            ['https://other.example.com/?q=https://example.com/?foo#bar'], // domain included inside string
+        ];
+    }
+
+    public function testChhotoError()
+    {
+        // Chhoto may reply with a body that contains neither "shorturl" nor
+        // "shortlink"; this must be handled gracefully as an error instead of
+        // raising a TypeError (the method is declared to return ?string).
+        file_put_contents($this->_mock_chhoto_service, '{"message":"error"}');
+
+        $chhoto = new ChhotoProxy($this->_conf, 'https://example.com/?foo#bar');
+        $this->assertTrue($chhoto->isError());
+        $this->assertEquals($chhoto->getError(), 'Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.');
+    }
+
+    public function testChhotoSuccessWithoutShortUrl()
+    {
+        // A 200-style reply that omits the short URL fields must be treated as
+        // an error, not as a successful (empty) shortening.
+        file_put_contents($this->_mock_chhoto_service, '{"status":"ok"}');
+
+        $chhoto = new ChhotoProxy($this->_conf, 'https://example.com/?foo#bar');
+        $this->assertTrue($chhoto->isError());
+        $this->assertEquals($chhoto->getError(), 'Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.');
+    }
+
+    public function testServerError()
+    {
+        // simulate some other server error that results in a non-JSON reply
+        file_put_contents($this->_mock_chhoto_service, '500 Internal Server Error');
+
+        $chhoto = new ChhotoProxy($this->_conf, 'https://example.com/?foo#bar');
+        $this->assertTrue($chhoto->isError());
+        $this->assertEquals($chhoto->getError(), 'Proxy error: Error parsing proxy response. This can be a configuration issue, like wrong or missing config keys.');
+    }
+}

+ 0 - 0
tst/YourlsProxyTest.php → tst/Proxy/YourlsProxyTest.php


+ 53 - 1
tst/RequestTest.php

@@ -20,7 +20,7 @@ class RequestTest extends TestCase
      */
     public function getRandomQueryChars()
     {
-        $queryChars     = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ=';
+        $queryChars     = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ=-_.!~*()';
         $queryCharCount = strlen($queryChars) - 1;
         $resultLength   = random_int(1, 10);
         $result         = '';
@@ -39,6 +39,30 @@ class RequestTest extends TestCase
         $this->assertEquals('view', $request->getOperation());
     }
 
+    /**
+     * @dataProvider provideRequestUris
+     */
+    public function testRequestUriValidation($uri, $expected)
+    {
+        $this->reset();
+        if ($uri !== null) {
+            $_SERVER['REQUEST_URI'] = $uri;
+        }
+        $request = new Request;
+        $this->assertSame($expected, $request->getRequestUri());
+    }
+
+    public function provideRequestUris()
+    {
+        return [
+            [null, '/'],
+            ['/', '/'],
+            ['/path/to/privatebin?query=value', '/path/to/privatebin'],
+            ['?query=value', '/'],
+            ['http://[', '/'],
+        ];
+    }
+
     public function testRead()
     {
         $this->reset();
@@ -178,6 +202,34 @@ class RequestTest extends TestCase
         }
     }
 
+    /**
+     * @dataProvider provideInvalidRoutingParameters
+     */
+    public function testPostInvalidRoutingParameters($parameters)
+    {
+        $this->reset();
+        $_SERVER['REQUEST_METHOD']        = 'POST';
+        $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
+        $file                             = Helper::createTempFile();
+        file_put_contents($file, json_encode($parameters));
+        Request::setInputStream($file);
+        $request = new Request;
+        unlink($file);
+        $this->assertSame('create', $request->getOperation());
+    }
+
+    public function provideInvalidRoutingParameters()
+    {
+        return [
+            [['pasteid' => '0123456789abcdef', 'deletetoken' => ['invalid']]],
+            [['pasteid' => '0123456789abcdef', 'deletetoken' => 123]],
+            [['pasteid' => ['0123456789abcdef'], 'deletetoken' => 'invalid']],
+            [['jsonld' => ['paste']]],
+            [['link' => ['https://example.com/'], 'shortenviayourls' => true]],
+            [['link' => 123, 'shortenviashlink' => true]],
+        ];
+    }
+
     public function testReadWithNegotiation()
     {
         $this->reset();

+ 3 - 0
tst/TemplateSwitcherTest.php

@@ -53,6 +53,9 @@ class TemplateSwitcherTest extends TestCase
 
         $_COOKIE['template'] = $escapeTemplateDirectory;
         $this->assertEquals($defaultTemplateFallback, TemplateSwitcher::getTemplate(), 'Fallback on escaping template directory');
+
+        $_COOKIE['template'] = [$customTemplate];
+        $this->assertEquals($defaultTemplateFallback, TemplateSwitcher::getTemplate(), 'Fallback on invalid cookie type');
     }
 
     public function testGetAvailableTemplates()

+ 11 - 0
tst/ViewTest.php

@@ -114,6 +114,17 @@ class ViewTest extends TestCase
         }
     }
 
+    public function testBootstrap5RetryButtonStartsHiddenInItsListItem()
+    {
+        $this->assertArrayHasKey('bootstrap5', $this->_content);
+        $this->assertMatchesRegularExpression(
+            '#<li class="nav-item d-flex flex-lg-row flex-column hidden">\s*'
+            . '<button id="retrybutton" type="button" class="reloadlink hidden\b#',
+            $this->_content['bootstrap5'],
+            'bootstrap5: retry button stays inside an initially hidden list item'
+        );
+    }
+
     public function testMissingTemplate()
     {
         $test = new View;

+ 1 - 0
vendor/composer/autoload_classmap.php

@@ -88,6 +88,7 @@ return array(
     'PrivateBin\\Persistence\\ServerSalt' => $baseDir . '/lib/Persistence/ServerSalt.php',
     'PrivateBin\\Persistence\\TrafficLimiter' => $baseDir . '/lib/Persistence/TrafficLimiter.php',
     'PrivateBin\\Proxy\\AbstractProxy' => $baseDir . '/lib/Proxy/AbstractProxy.php',
+    'PrivateBin\\Proxy\\ChhotoProxy' => $baseDir . '/lib/Proxy/ChhotoProxy.php',
     'PrivateBin\\Proxy\\ShlinkProxy' => $baseDir . '/lib/Proxy/ShlinkProxy.php',
     'PrivateBin\\Proxy\\YourlsProxy' => $baseDir . '/lib/Proxy/YourlsProxy.php',
     'PrivateBin\\Request' => $baseDir . '/lib/Request.php',

+ 1 - 0
vendor/composer/autoload_static.php

@@ -136,6 +136,7 @@ class ComposerStaticInitDontChange
         'PrivateBin\\Persistence\\ServerSalt' => __DIR__ . '/../..' . '/lib/Persistence/ServerSalt.php',
         'PrivateBin\\Persistence\\TrafficLimiter' => __DIR__ . '/../..' . '/lib/Persistence/TrafficLimiter.php',
         'PrivateBin\\Proxy\\AbstractProxy' => __DIR__ . '/../..' . '/lib/Proxy/AbstractProxy.php',
+        'PrivateBin\\Proxy\\ChhotoProxy' => __DIR__ . '/../..' . '/lib/Proxy/ChhotoProxy.php',
         'PrivateBin\\Proxy\\ShlinkProxy' => __DIR__ . '/../..' . '/lib/Proxy/ShlinkProxy.php',
         'PrivateBin\\Proxy\\YourlsProxy' => __DIR__ . '/../..' . '/lib/Proxy/YourlsProxy.php',
         'PrivateBin\\Request' => __DIR__ . '/../..' . '/lib/Request.php',

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác