Browse Source

Merge branch 'master' into add-zh-tw-locale

El RIDO 6 ngày trước cách đây
mục cha
commit
cf029101ee

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

+ 1 - 0
CHANGELOG.md

@@ -1,6 +1,7 @@
 # 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.

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

+ 43 - 43
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,29 @@
     "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"
 }

+ 1 - 1
js/privatebin.js

@@ -1457,7 +1457,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) {

+ 8 - 1
js/test/Model.js

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

+ 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-fPpM20CwlvwIrx9lMPrCAnow7B6WoN+Op11wjPBbPfZne8rLEOZICYs1Dh3lM7iD1OSn1SGa+Rl9PF6NHB/t4Q==',
+            '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==',

+ 5 - 1
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();
@@ -457,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;

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

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