27 Commity 16d26b5cd5 ... e5059f8c33

Autor SHA1 Správa Dátum
  El RIDO e5059f8c33 Merge branch 'add-zh-tw-locale' 6 dní pred
  El RIDO cf029101ee Merge branch 'master' into add-zh-tw-locale 6 dní pred
  El RIDO 13bddc9775 test: ensure tests conclude on low end development environments 6 dní pred
  El RIDO ebd4c1b3a5 test: fix accidentally broken tests due to merge of master branch 1 týždeň pred
  El RIDO 49aaf5b565 Merge pull request #2022 from PrivateBin/dependabot/npm_and_yarn/js/js-yaml-3.15.2 1 týždeň pred
  dependabot[bot] b205086f87 chore(deps-dev): bump js-yaml from 3.14.2 to 3.15.2 in /js 1 týždeň pred
  El RIDO 5a44b1e943 Merge branch 'Net0o-master' 1 týždeň pred
  El RIDO eb5c237e5b doc: help agents stick to our workflows 1 týždeň pred
  El RIDO 7794cef1c1 doc: newly added chhoto proxy feature 1 týždeň pred
  El RIDO 37088dadfa fix: chhoto URL fallback, found by tests 1 týždeň pred
  El RIDO dae840d688 test: add unit tests for new proxy 1 týždeň pred
  El RIDO af87b0f9cb chore: refresh auto loader by updating composer 1 týždeň pred
  El RIDO 181e20a77d chore: apply StyleCI recommendation 1 týždeň pred
  Dark77 cdfce1701e Update cfg/conf.sample.php 1 týždeň pred
  Dark77 e10590ef3f Update lib/Proxy/ChhotoProxy.php 1 týždeň pred
  El RIDO fa019e0d73 Merge branch 'master' into add-zh-tw-locale 1 týždeň pred
  Dark77 669e88069a Document Chotto API key and URL setup 1 mesiac pred
  Dark77 6757e5d035 Add shortenviachhoto parameter handling 1 mesiac pred
  Dark77 66e433084c Add ChhotoProxy support in Controller 1 mesiac pred
  Dark77 4ba7a70109 Add configuration for 'chhoto' API 1 mesiac pred
  Dark77 346264ebcc Add ChhotoProxy class for URL shortening 1 mesiac pred
  El RIDO 67c20dd21b fix regression 1 mesiac pred
  El RIDO 7b305332aa use modern string checks for readality 1 mesiac pred
  El RIDO ad2e842b79 apply Copilot styling suggestion 1 mesiac pred
  El RIDO c8b89c228c chore(doc): document changes 1 mesiac pred
  El RIDO 70595a72da apply StyleCI recommendation 1 mesiac pred
  Peter Dave Hello 9bf106b75e Add Traditional Chinese (Taiwan) locale (zh-tw) 3 mesiacov pred

+ 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.

+ 2 - 0
CHANGELOG.md

@@ -1,6 +1,8 @@
 # PrivateBin version history
 
 ## 2.1.0 (not yet released)
+* ADDED: Added `shortenviachhoto` endpoint with an `chhoto` configuration section
+* ADDED: Translation for Chinese (Traditional)
 * 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.

+ 1 - 0
CREDITS.md

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

+ 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);

+ 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,

+ 19 - 19
composer.lock

@@ -890,16 +890,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "9.6.35",
+            "version": "9.6.36",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b"
+                "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b",
-                "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/abab27ed286d3e1246fbbfe6b56bfd732d945ec9",
+                "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9",
                 "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.36"
             },
             "funding": [
                 {
@@ -981,7 +981,7 @@
                     "type": "other"
                 }
             ],
-            "time": "2026-07-06T14:48:07+00:00"
+            "time": "2026-08-11T06:25:15+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",

+ 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"],

+ 238 - 0
i18n/zh-tw.json

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

+ 1 - 1
js/common.js

@@ -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'];
 

+ 18 - 6
js/package-lock.json

@@ -653,10 +653,11 @@
       }
     },
     "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
-      "version": "3.14.2",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
-      "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
+      "version": "3.15.2",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
+      "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "argparse": "^1.0.7",
         "esprima": "^4.0.0"
@@ -2222,10 +2223,21 @@
       "license": "MIT"
     },
     "node_modules/js-yaml": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
-      "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+      "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
       "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/nodeca"
+        }
+      ],
+      "license": "MIT",
       "dependencies": {
         "argparse": "^2.0.1"
       },

+ 21 - 11
js/privatebin.js

@@ -492,11 +492,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 +665,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', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ro', 'ru', 'sk', 'sl', 'sv', 'th', 'tr', 'uk', 'zh', 'zh-tw'];
 
         /**
          * built in language
@@ -857,6 +855,7 @@ 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));
@@ -893,8 +892,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 +918,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;
@@ -963,7 +970,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;
@@ -1486,7 +1493,7 @@ window.PrivateBin = (function () {
          * @return {bool}
          */
         me.hasDeleteToken = function () {
-            return window.location.search.indexOf('deletetoken') !== -1;
+            return window.location.search.includes('deletetoken');
         };
 
         /**
@@ -3156,6 +3163,9 @@ 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).toLowerCase();

+ 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 });
         });
 
         /**

+ 42 - 0
js/test/I18n.js

@@ -216,6 +216,48 @@ describe('I18n', function () {
             ));
         });
 
+        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/'});
 

+ 5 - 1
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,7 +124,7 @@ 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-PWgNCOcRqGGY+BYvdaSKWMcnG+4OOphh8b0jYrvWMS9WJDodn55CKGmLBtEBnkbDh1sDqB0m3f6hgtWXckhVqw==',
+            'js/privatebin.js'       => 'sha512-EDBvid7ZFsTiqmEbYUR2Bwo7ypn7GKf+JwW6VFvdE6qLQbzdKrAla+AKhONnt/Tve3zEPc9bXI+4hUHl5itdZw==',
             'js/purify-3.4.12.js'    => 'sha512-Akf6HnAJZm0sWWWI4gp2GYff0NDnHUB02XJE5S7Hdq/Z5xtMjkuFacsDA8ZtViv1gi+onBxMhEMIaGyQeGxBng==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',

+ 9 - 2
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;
 
@@ -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();
@@ -204,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]);
         }
@@ -454,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;

+ 41 - 3
lib/I18n.php

@@ -177,7 +177,10 @@ class I18n
         // find a translation file matching the browsers language preferences
         else {
             self::$_language = self::_getMatchingLanguage(
-                self::getBrowserLanguages(), $availableLanguages
+                self::_normalizeBrowserLanguages(
+                    self::getBrowserLanguages(), $availableLanguages
+                ),
+                $availableLanguages
             );
         }
 
@@ -204,6 +207,7 @@ class I18n
             $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') {
@@ -249,6 +253,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
      *
@@ -306,8 +343,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,6 +401,7 @@ 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));

+ 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;
+    }
+}

+ 4 - 0
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 (
@@ -158,6 +159,9 @@ class Request
             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';
+            }
         }
     }
 

+ 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
      */

+ 26 - 2
tst/I18nTest.php

@@ -125,6 +125,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';
@@ -210,8 +231,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()
@@ -224,6 +245,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;
@@ -284,6 +306,8 @@ class I18nTest extends TestCase
                 $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;
             }

+ 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


+ 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',

+ 2 - 2
vendor/composer/installed.php

@@ -3,7 +3,7 @@
         'name' => 'privatebin/privatebin',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => '8a77305838325d7be15b9a96e43415a6d2796489',
+        'reference' => 'cdfce1701ec2b2c197bd1c43c72e79b21339b8ff',
         'type' => 'project',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -31,7 +31,7 @@
         'privatebin/privatebin' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => '8a77305838325d7be15b9a96e43415a6d2796489',
+            'reference' => 'cdfce1701ec2b2c197bd1c43c72e79b21339b8ff',
             'type' => 'project',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),