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

Merge branch 'master' into pastedecrypter-language-loaded-handler

El RIDO 1 месяц назад
Родитель
Сommit
9a64270826

+ 5 - 0
CHANGELOG.md

@@ -3,12 +3,17 @@
 ## 2.1.0 (not yet released)
 * ADDED: Added `shortenviachhoto` endpoint with an `chhoto` configuration section
 * ADDED: Translation for Chinese (Traditional)
+* ADDED: Subject line to the "Email" share button's mailto link (#928)
 * CHANGED: We removed jQuery in the Frontend and replaced it with vanilla JS.
 * CHANGED: Removed the unmaintained js-verify and replaced it with fast-check library.
 * CHANGED: Added a `jsconfig.json` in order to check the types of JavaScript.
 * CHANGED: Removed support for Bootstrap 3 as it requires jQuery.
+* CHANGED: Encode documents with `TextEncoder` in `compress()`, instead of the `utf16To8()` percent-encoding round trip, which allocated ~20 times the payload
+* CHANGED: Upgrading libraries to: ip-lib 1.22.1
+* FIXED: Decode decompressed documents with `TextDecoder` instead of `utf8To16()`, which allocated ~3.4 GB for a 100 MB document and killed the tab (#2027)
 * FIXED: Prevent browsers guessing MIME types on JSON(LD) API responses (#164)
 * FIXED: Insert quoted base path into JSON-LD documents
+* FIXED: Several potential issues in handling comment files (#1901)
 
 ## 2.0.6 (2026-08-08)
 * CHANGED: Stricter MIME type validation, divergent files get no preview and forced download link

+ 1 - 1
composer.json

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

+ 13 - 13
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "93df7ad74baea1e462b30852a480707d",
+    "content-hash": "527cd12822b23540702e67b958cbfdcf",
     "packages": [
         {
             "name": "jdenticon/jdenticon",
@@ -57,16 +57,16 @@
         },
         {
             "name": "mlocati/ip-lib",
-            "version": "1.22.0",
+            "version": "1.22.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/mlocati/ip-lib.git",
-                "reference": "4e40ffd3bf9989db19403d89c4d8be44b87b8a91"
+                "reference": "f096c0e9ec8bd4691ee45394cfb77c97c6bba272"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/mlocati/ip-lib/zipball/4e40ffd3bf9989db19403d89c4d8be44b87b8a91",
-                "reference": "4e40ffd3bf9989db19403d89c4d8be44b87b8a91",
+                "url": "https://api.github.com/repos/mlocati/ip-lib/zipball/f096c0e9ec8bd4691ee45394cfb77c97c6bba272",
+                "reference": "f096c0e9ec8bd4691ee45394cfb77c97c6bba272",
                 "shasum": ""
             },
             "require": {
@@ -112,7 +112,7 @@
             ],
             "support": {
                 "issues": "https://github.com/mlocati/ip-lib/issues",
-                "source": "https://github.com/mlocati/ip-lib/tree/1.22.0"
+                "source": "https://github.com/mlocati/ip-lib/tree/1.22.1"
             },
             "funding": [
                 {
@@ -124,7 +124,7 @@
                     "type": "other"
                 }
             ],
-            "time": "2025-10-15T12:35:09+00:00"
+            "time": "2026-09-17T09:24:44+00:00"
         },
         {
             "name": "symfony/polyfill-php80",
@@ -396,16 +396,16 @@
         },
         {
             "name": "nikic/php-parser",
-            "version": "v5.8.0",
+            "version": "v5.9.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
+                "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
-                "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def",
+                "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def",
                 "shasum": ""
             },
             "require": {
@@ -447,9 +447,9 @@
             ],
             "support": {
                 "issues": "https://github.com/nikic/PHP-Parser/issues",
-                "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
+                "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0"
             },
-            "time": "2026-07-04T14:30:18+00:00"
+            "time": "2026-09-13T18:51:52+00:00"
         },
         {
             "name": "phar-io/manifest",

+ 10 - 53
js/privatebin.js

@@ -1013,49 +1013,6 @@ window.PrivateBin = (function () {
          */
         const base58 = new baseX('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz');
 
-        /**
-         * convert UTF-8 string stored in a DOMString to a standard UTF-16 DOMString
-         *
-         * Iterates over the bytes of the message, converting them all hexadecimal
-         * percent encoded representations, then URI decodes them all
-         *
-         * @name   CryptTool.utf8To16
-         * @function
-         * @private
-         * @param  {string} message UTF-8 string
-         * @return {string} UTF-16 string
-         */
-        function utf8To16(message) {
-            return decodeURIComponent(
-                message.split('').map(
-                    function (character) {
-                        return '%' + ('00' + character.charCodeAt(0).toString(16)).slice(-2);
-                    }
-                ).join('')
-            );
-        }
-
-        /**
-         * convert DOMString (UTF-16) to a UTF-8 string stored in a DOMString
-         *
-         * URI encodes the message, then finds the percent encoded characters
-         * and transforms these hexadecimal representation back into bytes
-         *
-         * @name   CryptTool.utf16To8
-         * @function
-         * @private
-         * @param  {string} message UTF-16 string
-         * @return {string} UTF-8 string
-         */
-        function utf16To8(message) {
-            return encodeURIComponent(message).replace(
-                /%([0-9A-F]{2})/g,
-                function (match, hexCharacter) {
-                    return String.fromCharCode('0x' + hexCharacter);
-                }
-            );
-        }
-
         /**
          * convert ArrayBuffer into a UTF-8 string
          *
@@ -1107,9 +1064,7 @@ window.PrivateBin = (function () {
          * @return {ArrayBuffer} data
          */
         async function compress(message, mode, zlib) {
-            message = stringToArraybuffer(
-                utf16To8(message)
-            );
+            message = new TextEncoder().encode(message);
             if (mode === 'zlib') {
                 if (typeof zlib === 'undefined') {
                     throw new Error('Error compressing document, due to missing WebAssembly support.');
@@ -1141,9 +1096,7 @@ window.PrivateBin = (function () {
                     new Uint8Array(data)
                 ).buffer;
             }
-            return utf8To16(
-                arraybufferToString(data)
-            );
+            return new TextDecoder('utf-8', {fatal: true}).decode(data);
         }
 
         /**
@@ -4200,11 +4153,12 @@ window.PrivateBin = (function () {
          *
          * @name   TopNav.triggerEmailSend
          * @private
+         * @param {string} subject
          * @param {string} emailBody
          */
-        function triggerEmailSend(emailBody) {
+        function triggerEmailSend(subject, emailBody) {
             window.open(
-                `mailto:?body=${encodeURIComponent(emailBody)}`,
+                `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(emailBody)}`,
                 '_self',
                 'noopener, noreferrer'
             );
@@ -4228,6 +4182,9 @@ window.PrivateBin = (function () {
             );
             expirationDateRoundedToSecond.setUTCSeconds(0);
 
+            // reused as-is by both the confirmation-modal and direct-send paths below
+            const emailSubject = I18n._('Encrypted note on %s', document.title);
+
             const emailconfirmmodal = document.getElementById('emailconfirmmodal');
             if (expirationDate !== null) {
                 const emailconfirmTimezoneCurrent = emailconfirmmodal.querySelector('#emailconfirm-timezone-current');
@@ -4246,7 +4203,7 @@ window.PrivateBin = (function () {
                     if (bootstrap5EmailConfirmModal) {
                         bootstrap5EmailConfirmModal.hide();
                     }
-                    triggerEmailSend(emailBody);
+                    triggerEmailSend(emailSubject, emailBody);
                 }
 
                 emailconfirmmodal.addEventListener('shown.bs.modal', () => {
@@ -4264,7 +4221,7 @@ window.PrivateBin = (function () {
                     bootstrap5EmailConfirmModal.show();
                 }
             } else {
-                triggerEmailSend(templateEmailBody(null, isBurnafterreading));
+                triggerEmailSend(emailSubject, templateEmailBody(null, isBurnafterreading));
             }
         }
 

+ 32 - 0
js/test/Prompt.js

@@ -28,12 +28,43 @@ describe('Prompt', function () {
                                 </div>
                             </div>
                         </div>
+                        <nav class="navbar navbar-inverse navbar-static-top">
+                            <div id="navbar" class="navbar-collapse collapse">
+                            <ul class="nav navbar-nav"><li><button id="newbutton" type="button" class="hidden btn btn-warning navbar-btn">
+                            <span class="glyphicon glyphicon-file" aria-hidden="true">
+                                </span> New</button><button id="clonebutton" type="button" class="hidden btn btn-warning navbar-btn">
+                            <span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone</button>
+                            <button id="rawtextbutton" type="button" class="hidden btn btn-warning navbar-btn">
+                            <span class="glyphicon glyphicon-text-background" aria-hidden="true"></span> Raw text</button>
+                            <button id="downloadtextbutton" type="button" class="hidden btn btn-default navbar-btn"></button>
+                            <button id="qrcodelink" type="button" data-toggle="modal" data-target="#qrcodemodal" class="hidden btn btn-warning navbar-btn"/>
+                            <span class="glyphicon glyphicon-qrcode" aria-hidden="true"></span> QR code</button></li></ul></div>
+                        </nav>
+                        <div id="errormessage"></div>
+                        <div id="loadingindicator"></div>
+                        <div id="status"></div>
                     `;
 
                     // Initialize the Prompt module to set up event listeners
+                    PrivateBin.Alert.init();
+                    PrivateBin.TopNav.init();
                     PrivateBin.Prompt.init();
                     PrivateBin.Prompt.requestPassword();
 
+                    // mock request response
+                    const originalFetch = globalThis.fetch;
+                    globalThis.fetch = function (url) {
+                        assert.strictEqual(url, 'ftp://example.com/?pasteid=0000000000000000');
+                        return Promise.resolve({
+                            ok: true,
+                            status: 200,
+                            statusText: 'OK',
+                            json: function () {
+                                return Promise.resolve({});
+                            }
+                        });
+                    };
+
                     // Simulate user input
                     const passwordInput = document.getElementById('passworddecrypt');
                     passwordInput.value = password;
@@ -46,6 +77,7 @@ describe('Prompt', function () {
 
                     // Verify that getPassword returns the submitted password
                     const result = PrivateBin.Prompt.getPassword();
+                    globalThis.fetch = originalFetch;
                     clean();
                     return result === password;
                 }

+ 89 - 3
js/test/emailTemplateTest.js

@@ -81,10 +81,20 @@ function makeWindowOpenMock() {
 }
 
 
-// Extract and decode the body from a "mailto:?body=..." URL.
+// Extract and decode the body from a "mailto:?subject=...&body=..." URL.
 function extractMailtoBody(mailtoUrl) {
-    assert.match(mailtoUrl, /^mailto:\?body=/, 'expected a mailto:?body= URL');
-    return decodeURIComponent(mailtoUrl.replace(/^mailto:\?body=/, ''));
+    assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
+    const match = mailtoUrl.match(/[?&]body=([^&]*)/);
+    assert.ok(match, 'expected the mailto: URL to have a body= parameter');
+    return decodeURIComponent(match[1]);
+}
+
+// Extract and decode the subject from a "mailto:?subject=...&body=..." URL.
+function extractMailtoSubject(mailtoUrl) {
+    assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
+    const match = mailtoUrl.match(/[?&]subject=([^&]*)/);
+    assert.ok(match, 'expected the mailto: URL to have a subject= parameter');
+    return decodeURIComponent(match[1]);
 }
 
 describe('Email - mail body content (short URL vs. fallback)', function () {
@@ -166,3 +176,79 @@ describe('Email - mail body content (short URL vs. fallback)', function () {
         }
     });
 });
+
+describe('Email - mail subject', function () {
+    beforeEach(function () {
+        cleanup(); // provided by common
+    });
+
+    it('Includes a non-empty subject naming the instance, with no expiration confirmation step', function () {
+        buildEmailDomNoShortUrl();
+        // buildEmailDomNoShortUrl() replaces documentElement.innerHTML (dropping <title>),
+        // so document.title must be (re-)set after calling it, not before.
+        document.title = 'My PrivateBin Instance';
+        PrivateBin.TopNav.init();
+        PrivateBin.TopNav.showEmailButton(0);
+
+        const { getUrl, restore } = makeWindowOpenMock();
+        try {
+            document.getElementById('emaillink').click();
+
+            const openedUrl = getUrl();
+            assert.ok(openedUrl, 'window.open should have been called');
+
+            const subject = extractMailtoSubject(openedUrl);
+            assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
+        } finally {
+            restore();
+        }
+    });
+
+    it('Includes the same subject after the expiration confirmation step', function () {
+        buildEmailDomWithShortUrl();
+        document.title = 'My PrivateBin Instance';
+        PrivateBin.TopNav.init();
+        // a non-zero remaining time routes through the timezone confirmation modal
+        PrivateBin.TopNav.showEmailButton(3600);
+
+        const { getUrl, restore } = makeWindowOpenMock();
+        try {
+            document.getElementById('emaillink').click();
+            document.getElementById('emailconfirm-timezone-current').click();
+
+            const openedUrl = getUrl();
+            assert.ok(openedUrl, 'window.open should have been called');
+
+            const subject = extractMailtoSubject(openedUrl);
+            assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
+        } finally {
+            restore();
+        }
+    });
+
+    it('Percent-encodes the subject so a space-containing instance name does not break the mailto URL', function () {
+        buildEmailDomNoShortUrl();
+        document.title = 'My Cool Instance';
+        PrivateBin.TopNav.init();
+        PrivateBin.TopNav.showEmailButton(0);
+
+        const { getUrl, restore } = makeWindowOpenMock();
+        try {
+            document.getElementById('emaillink').click();
+
+            const openedUrl = getUrl();
+            const rawSubjectParam = openedUrl.split('&body=')[0];
+            assert.strictEqual(
+                rawSubjectParam,
+                `mailto:?subject=${encodeURIComponent('Encrypted note on My Cool Instance')}`,
+                'raw subject parameter should be exactly the encoded translated string'
+            );
+            assert.doesNotMatch(rawSubjectParam, / /, 'raw mailto URL must not contain a literal space');
+
+            const subject = extractMailtoSubject(openedUrl);
+            assert.strictEqual(subject, 'Encrypted note on My Cool Instance', 'decoded subject should round-trip exactly');
+        } finally {
+            restore();
+        }
+    });
+});

+ 1 - 1
lib/Configuration.php

@@ -124,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-3zy2f0oCXDWD3oeXQlIvtHZ1HS9yV1JIPrCbXgMFafJoAPf7+zFVvTWffc5eK2OC5CQOkOJFFaoTwGqMkjximQ==',
+            'js/privatebin.js'       => 'sha512-JubZJZ1CCclHtL+Cw4CQVHjzoWhY5wXb4bVpVoIsnl5z6GJd21pUgxqLsapVP6nJyvrLEqu5Y3fZjD6UWlIrqg==',
             '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==',

+ 48 - 16
lib/Data/Filesystem.php

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

+ 35 - 0
tst/Data/FilesystemTest.php

@@ -75,6 +75,41 @@ class FilesystemTest extends TestCase
         $this->assertEquals($original, $this->_model->read(Helper::getPasteId()));
     }
 
+    public function testCorruptCommentsAreIgnored()
+    {
+        $pasteid   = Helper::getPasteId();
+        $commentid = Helper::getCommentId();
+        $comment   = Helper::getComment();
+        $this->assertTrue($this->_model->createComment($pasteid, $pasteid, $commentid, $comment));
+
+        $discussionPath = $this->_path . DIRECTORY_SEPARATOR . substr($pasteid, 0, 2) .
+            DIRECTORY_SEPARATOR . substr($pasteid, 2, 2) . DIRECTORY_SEPARATOR .
+            $pasteid . '.discussion' . DIRECTORY_SEPARATOR;
+        file_put_contents(
+            $discussionPath . $pasteid . '.ffffffffffffffff.' . $pasteid . '.php',
+            Filesystem::PROTECTION_LINE . PHP_EOL . '{'
+        );
+        file_put_contents(
+            $discussionPath . $pasteid . '.invalid.invalid.php',
+            Filesystem::PROTECTION_LINE . PHP_EOL . json_encode($comment)
+        );
+        file_put_contents(
+            $discussionPath . 'ffffffffffffffff.eeeeeeeeeeeeeeee.' . $pasteid . '.php',
+            Filesystem::PROTECTION_LINE . PHP_EOL . json_encode($comment)
+        );
+        file_put_contents(
+            $discussionPath . $pasteid . '.dddddddddddddddd.' . $pasteid,
+            Filesystem::PROTECTION_LINE . PHP_EOL . json_encode($comment)
+        );
+
+        $errorLog = ini_get('error_log');
+        ini_set('error_log', '/dev/null');
+        $comments = $this->_model->readComments($pasteid);
+        ini_set('error_log', $errorLog);
+        $this->assertCount(1, $comments);
+        $this->assertSame($commentid, current($comments)['id']);
+    }
+
     /**
      * pastes a-g are expired and should get deleted, x never expires and y-z expire in an hour
      */

+ 5 - 5
vendor/composer/installed.php

@@ -3,7 +3,7 @@
         'name' => 'privatebin/privatebin',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => 'cdfce1701ec2b2c197bd1c43c72e79b21339b8ff',
+        'reference' => '07c71be780ccd004802a0294747759af272efc98',
         'type' => 'project',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -20,9 +20,9 @@
             'dev_requirement' => false,
         ),
         'mlocati/ip-lib' => array(
-            'pretty_version' => '1.22.0',
-            'version' => '1.22.0.0',
-            'reference' => '4e40ffd3bf9989db19403d89c4d8be44b87b8a91',
+            'pretty_version' => '1.22.1',
+            'version' => '1.22.1.0',
+            'reference' => 'f096c0e9ec8bd4691ee45394cfb77c97c6bba272',
             'type' => 'library',
             'install_path' => __DIR__ . '/../mlocati/ip-lib',
             'aliases' => array(),
@@ -31,7 +31,7 @@
         'privatebin/privatebin' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => 'cdfce1701ec2b2c197bd1c43c72e79b21339b8ff',
+            'reference' => '07c71be780ccd004802a0294747759af272efc98',
             'type' => 'project',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),

+ 3 - 3
vendor/mlocati/ip-lib/src/Address/IPv4.php

@@ -126,7 +126,7 @@ class IPv4 implements AddressInterface
         $flags = (int) $flags;
         $matches = null;
         if ($flags & ParseStringFlag::ADDRESS_MAYBE_RDNS) {
-            if (preg_match('/^([12]?[0-9]{1,2}\.[12]?[0-9]{1,2}\.[12]?[0-9]{1,2}\.[12]?[0-9]{1,2})\.in-addr\.arpa\.?$/i', $address, $matches)) {
+            if (preg_match('/^([12]?[0-9]{1,2}\.[12]?[0-9]{1,2}\.[12]?[0-9]{1,2}\.[12]?[0-9]{1,2})\.in-addr\.arpa\.?$/Di', $address, $matches)) {
                 $address = implode('.', array_reverse(explode('.', $matches[1])));
                 $flags = $flags & ~(ParseStringFlag::IPV4_MAYBE_NON_DECIMAL | ParseStringFlag::IPV4ADDRESS_MAYBE_NON_QUAD_DOTTED);
             }
@@ -162,7 +162,7 @@ class IPv4 implements AddressInterface
         if ($flags & ParseStringFlag::MAY_INCLUDE_PORT) {
             $rx .= '(?::\d+)?';
         }
-        if (!preg_match('/^' . $rx . '$/', $address, $matches)) {
+        if (!preg_match('/^' . $rx . '$/D', $address, $matches)) {
             return null;
         }
         $math = new \IPLib\Service\UnsignedIntegerMath();
@@ -535,7 +535,7 @@ class IPv4 implements AddressInterface
         }
         $absBits = abs($bits);
         if ($absBits >= 32) {
-            return new self('0.0.0.0');
+            return new static('0.0.0.0');
         }
         $pad = str_repeat('0', $absBits);
         $paddedBits = $this->getBits();

+ 5 - 5
vendor/mlocati/ip-lib/src/Address/IPv6.php

@@ -136,7 +136,7 @@ class IPv6 implements AddressInterface
         $matches = null;
         $flags = (int) $flags;
         if ($flags & ParseStringFlag::ADDRESS_MAYBE_RDNS) {
-            if (preg_match('/^([0-9a-f](?:\.[0-9a-f]){31})\.ip6\.arpa\.?/i', $address, $matches)) {
+            if (preg_match('/^([0-9a-f](?:\.[0-9a-f]){31})\.ip6\.arpa\.?$/Di', $address, $matches)) {
                 $nibbles = array_reverse(explode('.', $matches[1]));
                 $quibbles = array();
                 foreach (array_chunk($nibbles, 4) as $n) {
@@ -147,7 +147,7 @@ class IPv6 implements AddressInterface
         }
         $result = null;
         if (strpos($address, ':') !== false && strpos($address, ':::') === false) {
-            if ($flags & ParseStringFlag::MAY_INCLUDE_PORT && $address[0] === '[' && preg_match('/^\[(.+)]:\d+$/', $address, $matches)) {
+            if ($flags & ParseStringFlag::MAY_INCLUDE_PORT && $address[0] === '[' && preg_match('/^\[(.+)]:\d+$/D', $address, $matches)) {
                 $address = $matches[1];
             }
             if ($flags & ParseStringFlag::MAY_INCLUDE_ZONEID) {
@@ -156,7 +156,7 @@ class IPv6 implements AddressInterface
                     $address = substr($address, 0, $percentagePos);
                 }
             }
-            if (preg_match('/^((?:[0-9a-f]*:+)+)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i', $address, $matches)) {
+            if (preg_match('/^((?:[0-9a-f]*:+)+)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/Di', $address, $matches)) {
                 $address6 = static::parseString($matches[1] . '0:0');
                 if ($address6 !== null) {
                     $address4 = IPv4::parseString($matches[2]);
@@ -188,7 +188,7 @@ class IPv6 implements AddressInterface
                 if (count($chunks) === 8) {
                     $nums = array_map(
                         function ($chunk) {
-                            return preg_match('/^[0-9A-Fa-f]{1,4}$/', $chunk) ? hexdec($chunk) : false;
+                            return preg_match('/^[0-9A-Fa-f]{1,4}$/D', $chunk) ? hexdec($chunk) : false;
                         },
                         $chunks
                     );
@@ -629,7 +629,7 @@ class IPv6 implements AddressInterface
         }
         $absBits = abs($bits);
         if ($absBits >= 128) {
-            return new self('0000:0000:0000:0000:0000:0000:0000:0000');
+            return new static('0000:0000:0000:0000:0000:0000:0000:0000');
         }
         $pad = str_repeat('0', $absBits);
         $paddedBits = $this->getBits();

+ 8 - 2
vendor/mlocati/ip-lib/src/Range/AbstractRange.php

@@ -26,12 +26,18 @@ abstract class AbstractRange implements RangeInterface
         // @phpstan-ignore varTag.nativeType
         if ($this->rangeType === null) {
             $addressType = $this->getAddressType();
-            if ($addressType === AddressType::T_IPv6 && Subnet::get6to4()->containsRange($this)) {
+            $fromIPv4 = $toIPv4 = null;
+            if ($addressType === AddressType::T_IPv6) {
                 $fromAddress = $this->fromAddress;
                 /** @var IPv6 $fromAddress */
                 $toAddress = $this->toAddress;
                 /** @var IPv6 $toAddress */
-                $range = Factory::getRangeFromBoundaries($fromAddress->toIPv4(), $toAddress->toIPv4());
+                // 6to4 and IPv4-mapped ranges: use the type of the corresponding IPv4 range
+                $fromIPv4 = $fromAddress->toIPv4();
+                $toIPv4 = $toAddress->toIPv4();
+            }
+            if ($fromIPv4 !== null && $toIPv4 !== null) {
+                $range = Factory::getRangeFromBoundaries($fromIPv4, $toIPv4);
                 /** @var RangeInterface $range */
                 $this->rangeType = $range->getRangeType();
             } else {

+ 15 - 11
vendor/mlocati/ip-lib/src/Range/Pattern.php

@@ -124,7 +124,7 @@ class Pattern extends AbstractRange
             return new static($fromAddress, $toAddress, 8);
         }
         $matches = null;
-        if (strpos($range, '.') !== false && preg_match('/^[^*]+((?:\.\*)+)$/', $range, $matches)) {
+        if (strpos($range, '.') !== false && preg_match('/^[^*]+((?:\.\*)+)$/D', $range, $matches)) {
             $asterisksCount = strlen($matches[1]) >> 1;
             if ($asterisksCount > 0) {
                 $missingDots = 3 - substr_count($range, '.');
@@ -144,9 +144,9 @@ class Pattern extends AbstractRange
 
             return new static($fromAddress, $toAddress, $asterisksCount);
         }
-        if (strpos($range, ':') !== false && preg_match('/^[^*]+((?::\*)+)$/', $range, $matches)) {
+        if (strpos($range, ':') !== false && preg_match('/^[^*]+((?::\*)+)$/D', $range, $matches)) {
             $asterisksCount = strlen($matches[1]) >> 1;
-            $fromAddress = IPv6::parseString(str_replace('*', '0', $range));
+            $fromAddress = IPv6::parseString(str_replace('*', '0', $range), $flags);
             if ($fromAddress === null) {
                 return null;
             }
@@ -187,17 +187,21 @@ class Pattern extends AbstractRange
                 } elseif ($this->asterisksCount === 8) {
                     $result = '*:*:*:*:*:*:*:*';
                 } else {
-                    $bytes = $this->toAddress->getBytes();
-                    $bytes = array_slice($bytes, 0, -$this->asterisksCount * 2);
-                    $bytes = array_pad($bytes, 16, 1);
-                    $address = IPv6::fromBytes($bytes);
-                    /** @var IPv6 $address */
-                    $before = substr($address->toString(false), 0, -strlen(':101') * $this->asterisksCount);
-                    $result = $before . str_repeat(':*', $this->asterisksCount);
+                    $numFixedWords = 8 - $this->asterisksCount;
+                    $chunks = array_map('dechex', array_slice($this->fromAddress->getWords(), 0, $numFixedWords));
+                    $result = implode(':', array_pad($chunks, 8, '*'));
+                    $matches = null;
+                    for ($i = $numFixedWords; $i > 1; $i--) {
+                        $search = '(?:^|:)' . rtrim(str_repeat('0:', $i), ':') . ':';
+                        if (preg_match('/^(.*?)' . $search . '(.*)$/', $result, $matches)) {
+                            $result = $matches[1] . '::' . $matches[2];
+                            break;
+                        }
+                    }
                 }
                 break;
             default:
-                throw new \Exception('@todo'); // @codeCoverageIgnore
+                throw new \RuntimeException('Unsupported address type: ' . get_class($this->fromAddress)); // @codeCoverageIgnore
         }
 
         return $result;

+ 3 - 6
vendor/mlocati/ip-lib/src/Range/Single.php

@@ -256,11 +256,8 @@ class Single extends AbstractRange
      */
     public function getNetworkPrefix()
     {
-        switch ($this->getAddressType()) {
-            case AddressType::T_IPv4:
-                return 32;
-            case AddressType::T_IPv6:
-                return 128;
-        }
+        $address = $this->address;
+
+        return $address::getNumberOfBits();
     }
 }

+ 6 - 6
vendor/mlocati/ip-lib/src/Range/Subnet.php

@@ -43,7 +43,7 @@ class Subnet extends AbstractRange
     /**
      * The type of the range of this IP range.
      *
-     * @var int|null
+     * @var int|false|null false if this range crosses multiple range types, null if yet to be determined
      *
      * @since 1.5.0
      */
@@ -131,7 +131,7 @@ class Subnet extends AbstractRange
         if ($address === null) {
             return null;
         }
-        if (!preg_match('/^[0-9]{1,9}$/', $parts[1])) {
+        if (!preg_match('/^[0-9]{1,9}$/D', $parts[1])) {
             return null;
         }
         $networkPrefix = (int) $parts[1];
@@ -237,13 +237,13 @@ class Subnet extends AbstractRange
      */
     public function asPattern()
     {
-        $address = $this->getStartAddress();
+        $startAddress = $this->getStartAddress();
         $networkPrefix = $this->getNetworkPrefix();
-        switch ($address->getAddressType()) {
+        switch ($startAddress->getAddressType()) {
             case AddressType::T_IPv4:
-                return $networkPrefix % 8 === 0 ? new Pattern($address, $address, 4 - $networkPrefix / 8) : null;
+                return $networkPrefix % 8 === 0 ? new Pattern($startAddress, $this->getEndAddress(), 4 - $networkPrefix / 8) : null;
             case AddressType::T_IPv6:
-                return $networkPrefix % 16 === 0 ? new Pattern($address, $address, 8 - $networkPrefix / 16) : null;
+                return $networkPrefix % 16 === 0 ? new Pattern($startAddress, $this->getEndAddress(), 8 - $networkPrefix / 16) : null;
         }
     }
 

+ 4 - 4
vendor/mlocati/ip-lib/src/Range/Type.php

@@ -50,7 +50,7 @@ class Type
     const T_LIMITEDBROADCAST = 6;
 
     /**
-     * Multicast address assignments - Indentify a group of interfaces.
+     * Multicast address assignments - Identify a group of interfaces.
      *
      * @var int
      */
@@ -64,7 +64,7 @@ class Type
     const T_LINKLOCAL = 8;
 
     /**
-     * Link local unicast / Linked-scoped unicast.
+     * Link local unicast / Link-scoped unicast.
      *
      * @var int
      */
@@ -130,11 +130,11 @@ class Type
             case static::T_LIMITEDBROADCAST:
                 return '"Limited broadcast" destination address';
             case static::T_MULTICAST:
-                return 'Multicast address assignments - Indentify a group of interfaces';
+                return 'Multicast address assignments - Identify a group of interfaces';
             case static::T_LINKLOCAL:
                 return '"Link local" address, allocated for communication between hosts on a single link';
             case static::T_LINKLOCAL_UNICAST:
-                return 'Link local unicast / Linked-scoped unicast';
+                return 'Link local unicast / Link-scoped unicast';
             case static::T_DISCARDONLY:
                 return 'Discard only';
             case static::T_DISCARD:

+ 3 - 3
vendor/mlocati/ip-lib/src/Service/BinaryMath.php

@@ -46,7 +46,7 @@ class BinaryMath
      * @param string $a
      * @param string $b
      *
-     * @return int 1 if $a is greater than $b, -1 if $b is greater than $b, 0 if they are the same
+     * @return int 1 if $a is greater than $b, -1 if $b is greater than $a, 0 if they are the same
      */
     public function compare($a, $b)
     {
@@ -162,7 +162,7 @@ class BinaryMath
             $value = substr($value, 1);
         }
         $matches = null;
-        if (!preg_match('/^0*([0-9]+)$/', $value, $matches)) {
+        if (!preg_match('/^0*([0-9]+)$/D', $value, $matches)) {
             return '';
         }
         $numericString = $matches[1];
@@ -229,7 +229,7 @@ class BinaryMath
      * @param string $num1
      * @param string $num2
      *
-     * @return array{string, string, int} The first array element is $num1 (padded), the first array element is $num2 (padded), the third array element is the number of bits
+     * @return array{string, string, int} The first array element is $num1 (padded), the second array element is $num2 (padded), the third array element is the number of bits
      */
     private function toSameLength($num1, $num2)
     {

+ 1 - 3
vendor/mlocati/ip-lib/src/Service/NumberInChunks.php

@@ -43,8 +43,6 @@ class NumberInChunks
     }
 
     /**
-     * @throws \InvalidArgumentException if $other has a $chunkSize that's not the same as the $chunkSize of this
-     *
      * @return \IPLib\Service\NumberInChunks
      */
     public function negate()
@@ -53,7 +51,7 @@ class NumberInChunks
     }
 
     /**
-     * @throws \InvalidArgumentException if $other has a $chunkSize that's not the same as the $chunkSize of this
+     * @throws \InvalidArgumentException if $that has a $chunkSize that's not the same as the $chunkSize of this
      *
      * @return \IPLib\Service\NumberInChunks
      */

+ 4 - 4
vendor/mlocati/ip-lib/src/Service/UnsignedIntegerMath.php

@@ -22,17 +22,17 @@ class UnsignedIntegerMath
     {
         $m = null;
         if ($onlyDecimal) {
-            if (preg_match('/^0*(\d+)$/', $value, $m)) {
+            if (preg_match('/^0*(\d+)$/D', $value, $m)) {
                 return $this->getBytesFromDecimal($m[1], $numBytes);
             }
         } else {
-            if (preg_match('/^0[Xx]0*([0-9A-Fa-f]+)$/', $value, $m)) {
+            if (preg_match('/^0[Xx]0*([0-9A-Fa-f]+)$/D', $value, $m)) {
                 return $this->getBytesFromHexadecimal($m[1], $numBytes);
             }
-            if (preg_match('/^0+([0-7]*)$/', $value, $m)) {
+            if (preg_match('/^0+([0-7]*)$/D', $value, $m)) {
                 return $this->getBytesFromOctal($m[1], $numBytes);
             }
-            if (preg_match('/^[1-9][0-9]*$/', $value)) {
+            if (preg_match('/^[1-9][0-9]*$/D', $value)) {
                 return $this->getBytesFromDecimal($value, $numBytes);
             }
         }