Kaynağa Gözat

Merge remote-tracking branch 'origin/master' into 2.0.6-forwardport

rugk 1 hafta önce
ebeveyn
işleme
51aa17473f

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

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

+ 2 - 0
CHANGELOG.md

@@ -5,6 +5,8 @@
 * 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.
+* FIXED: Prevent browsers guessing MIME types on JSON(LD) API responses (#164)
+* FIXED: Insert quoted base path into JSON-LD documents
 
 ## 2.0.6 (2026-08-08)
 * CHANGED: Stricter MIME type validation, divergent files get no preview and forced download link

+ 8 - 4
bin/administration

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

+ 19 - 5
bin/migrate

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

+ 6 - 6
i18n/bg.json

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

+ 1 - 1
i18n/co.json

@@ -170,7 +170,7 @@
     "Markdown": "Markdown",
     "Download attachment": "Scaricà a pezza ghjunta",
     "Cloned: '%s'": "Duppiatu : « %s »",
-    "The cloned file '%s' was attached to this document.": "U schedariu duppiatu « %s » hè statu aghjuntu à stu ducumentu.",
+    "The cloned file '%s' was attached to this document.": "U schedariu duppiatu « %s » hè statu ghjuntu à stu ducumentu.",
     "Attach a file": "Aghjunghje un schedariu",
     "alternatively drag & drop a file or paste an image from the clipboard": "in alternanza, sguillà è depone un schedariu o incullà una fiura da u preme’papei",
     "File too large, to display a preview. Please download the attachment.": "Schedariu troppu maiò per affissà una fighjulata. Scaricate a pezza ghjunta.",

+ 1 - 1
i18n/sl.json

@@ -232,7 +232,7 @@
     "Show password": "Pokaži geslo",
     "Hide password": "Skrij geslo",
     "Theme": "Tema",
-    "Copy": "Copy",
+    "Copy": "Kopiraj",
     "Cmd": "Cmd",
     "Ctrl": "Ctrl"
 }

Dosya farkı çok büyük olduğundan ihmal edildi
+ 258 - 372
js/package-lock.json


+ 1 - 1
js/package.json

@@ -15,7 +15,7 @@
     "jshint": "^2.13.6",
     "jsverify": "^0.8.3",
     "mime-types": "^3.0.2",
-    "mocha": "^11.7.5",
+    "mocha": "^12.0.0",
     "nyc": "^18.0.0"
   },
   "scripts": {

+ 8 - 3
js/privatebin.js

@@ -4364,15 +4364,17 @@ window.PrivateBin = (function () {
          * @name   TopNav.showEmailbutton
          * @function
          * @param {number|undefined} optionalRemainingTimeInSeconds
+         * @param {bool|undefined} optionalBurnAfterReading
          */
-        me.showEmailButton = function (optionalRemainingTimeInSeconds) {
+        me.showEmailButton = function (optionalRemainingTimeInSeconds, optionalBurnAfterReading) {
             try {
                 // we cache expiration date in closure to avoid inaccurate expiration datetime
                 const expirationDate = Helper.calculateExpirationDate(
                     new Date(),
                     typeof optionalRemainingTimeInSeconds === 'number' ? optionalRemainingTimeInSeconds : TopNav.getExpiration()
                 );
-                const isBurnafterreading = TopNav.getBurnAfterReading();
+                const isBurnafterreading = typeof optionalBurnAfterReading === 'boolean' ?
+                    optionalBurnAfterReading : TopNav.getBurnAfterReading();
 
                 emailLink.classList.remove('hidden');
                 emailLink.removeEventListener('click', sendEmail);
@@ -5579,7 +5581,10 @@ window.PrivateBin = (function () {
                         TopNav.hideBurnAfterReadingButtons();
                     } else {
                         // we have to pass in remaining_time here
-                        TopNav.showEmailButton(paste.getTimeToLive());
+                        TopNav.showEmailButton(
+                            paste.getTimeToLive(),
+                            paste.isBurnAfterReadingEnabled()
+                        );
                     }
 
                     // only offer adding comments, after document was successfully decrypted

+ 26 - 0
js/test/emailTemplateTest.js

@@ -139,4 +139,30 @@ describe('Email - mail body content (short URL vs. fallback)', function () {
             restore();
         }
     });
+
+    it('Uses the viewed paste burn-after-reading state instead of the instance default', function () {
+        buildEmailDomNoShortUrl();
+        const burnAfterReading = document.createElement('input');
+        burnAfterReading.id = 'burnafterreading';
+        burnAfterReading.type = 'checkbox';
+        burnAfterReading.checked = true;
+        document.body.appendChild(burnAfterReading);
+        PrivateBin.TopNav.init();
+        PrivateBin.TopNav.showEmailButton(0, false);
+
+        const { getUrl, restore } = makeWindowOpenMock();
+        try {
+            document.getElementById('emaillink').click();
+            document.getElementById('emailconfirm-timezone-current').click();
+
+            const body = extractMailtoBody(getUrl());
+            assert.doesNotMatch(
+                body,
+                /only be accessed once/,
+                'email body must use the viewed paste metadata'
+            );
+        } finally {
+            restore();
+        }
+    });
 });

+ 3 - 1
lib/Controller.php

@@ -168,6 +168,7 @@ class Controller
             header('Access-Control-Allow-Origin: *');
             header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE');
             header('Access-Control-Allow-Headers: X-Requested-With, Content-Type');
+            header('X-Content-Type-Options: nosniff');
             header('X-Uncompressed-Content-Length: ' . strlen($this->_json));
             header('Access-Control-Expose-Headers: X-Uncompressed-Content-Length');
             echo $this->_json;
@@ -513,7 +514,7 @@ class Controller
         if (is_readable($file)) {
             $content = str_replace(
                 '?jsonld=',
-                $this->_urlBase . '?jsonld=',
+                trim(Json::encode($this->_urlBase), '"') . '?jsonld=',
                 file_get_contents($file)
             );
         }
@@ -528,6 +529,7 @@ class Controller
         header('Content-type: application/ld+json');
         header('Access-Control-Allow-Origin: *');
         header('Access-Control-Allow-Methods: GET');
+        header('X-Content-Type-Options: nosniff');
         echo $content;
     }
 

+ 13 - 0
lib/Data/AbstractData.php

@@ -195,4 +195,17 @@ abstract class AbstractData
         }
         return $created;
     }
+
+    /**
+     * Sort comments chronologically, including collision suffixes.
+     *
+     * @access protected
+     * @param  array $comments
+     * @return array
+     */
+    protected function sortComments(array $comments)
+    {
+        ksort($comments, SORT_NATURAL);
+        return $comments;
+    }
 }

+ 1 - 2
lib/Data/Database.php

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

+ 2 - 4
lib/Data/Filesystem.php

@@ -228,11 +228,8 @@ class Filesystem extends AbstractData
                     $comments[$key] = $comment;
                 }
             }
-
-            // Sort comments by date, oldest first.
-            ksort($comments);
         }
-        return $comments;
+        return $this->sortComments($comments);
     }
 
     /**
@@ -520,6 +517,7 @@ class Filesystem extends AbstractData
             file_put_contents($destFile, self::PROTECTION_LINE . PHP_EOL);
             file_put_contents($destFile, $handle, FILE_APPEND);
             fclose($handle);
+            chmod($destFile, 0640); // protect file from access by other users on the host
         }
         if (!unlink($srcFile)) {
             error_log('Error deleting converted document: ' . $srcFile);

+ 1 - 1
lib/Data/GoogleCloudStorage.php

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

+ 1 - 1
lib/Data/S3Storage.php

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

+ 1 - 1
lib/I18n.php

@@ -294,7 +294,7 @@ class I18n
      */
     public static function isRtl()
     {
-        return in_array(self::$_language, ['ar', 'he']);
+        return in_array(self::$_language, ['ar', 'fa', 'he']);
     }
 
     /**

+ 52 - 0
tst/AdministrationEmptyDirsTest.php

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

+ 42 - 0
tst/AdministrationOptionsTest.php

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

+ 29 - 0
tst/Data/DatabaseTest.php

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

+ 16 - 1
tst/Data/FilesystemTest.php

@@ -163,16 +163,31 @@ class FilesystemTest extends TestCase
             file_put_contents($storagedir . $dataid . '.' . $commentid . '.' . $dataid, json_encode($comment));
         }
         // check that all 10 pastes were converted after the purge
-        $this->_model->purge(10);
+        $oldUmask = umask(0000);
+        try {
+            $this->_model->purge(10);
+        } finally {
+            umask($oldUmask);
+        }
         foreach ($ids as $dataid => $storagedir) {
             $dataid = (string) $dataid; // undue potential key cast, see https://www.php.net/manual/en/language.types.array.php
             $this->assertFileExists($storagedir . $dataid . '.php', "paste $dataid exists in new format");
+            $this->assertSame(
+                0640,
+                fileperms($storagedir . $dataid . '.php') & 0777,
+                "converted paste $dataid has protected permissions"
+            );
             $this->assertFileDoesNotExist($storagedir . $dataid, "old format paste $dataid got removed");
             $this->assertTrue($this->_model->exists($dataid), "paste $dataid exists");
             $this->assertEquals($this->_model->read($dataid), $paste, "paste $dataid wasn't modified in the conversion");
 
             $storagedir .= $dataid . '.discussion' . DIRECTORY_SEPARATOR;
             $this->assertFileExists($storagedir . $dataid . '.' . $commentid . '.' . $dataid . '.php', "comment of $dataid exists in new format");
+            $this->assertSame(
+                0640,
+                fileperms($storagedir . $dataid . '.' . $commentid . '.' . $dataid . '.php') & 0777,
+                "converted comment of $dataid has protected permissions"
+            );
             $this->assertFileDoesNotExist($storagedir . $dataid . '.' . $commentid . '.' . $dataid, "old format comment of $dataid got removed");
             $this->assertTrue($this->_model->existsComment($dataid, $dataid, $commentid), "comment in paste $dataid exists");
             $comment             = $comment;

+ 28 - 0
tst/I18nTest.php

@@ -80,6 +80,21 @@ class I18nTest extends TestCase
         $this->assertEquals('2 heures', I18n::_('%d hours', 2), '2 hours in French');
     }
 
+    public function testBulgarianMonthExpirationTranslation()
+    {
+        $_COOKIE['lang'] = 'bg';
+        I18n::loadTranslations();
+
+        $this->assertEquals(
+            'Този документ изтича след един месец.',
+            I18n::_('This document will expire in %d months.', 1)
+        );
+        $this->assertEquals(
+            'Този документ изтича след 2 месеца.',
+            I18n::_('This document will expire in %d months.', 2)
+        );
+    }
+
     public function testBrowserLanguageNoDetection()
     {
         $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'no;q=0.8,en-GB;q=0.6,en-US;q=0.4,en;q=0.2';
@@ -246,6 +261,19 @@ class I18nTest extends TestCase
         $this->assertEquals('Ctrl', I18n::getCopyHotkey(), 'returns Ctrl when user agent absent');
     }
 
+    public function testRightToLeftLanguages()
+    {
+        foreach (['ar', 'fa', 'he'] as $language) {
+            $_COOKIE['lang'] = $language;
+            I18n::loadTranslations();
+            $this->assertTrue(I18n::isRtl(), "$language is right-to-left");
+        }
+
+        $_COOKIE['lang'] = 'en';
+        I18n::loadTranslations();
+        $this->assertFalse(I18n::isRtl(), 'English is left-to-right');
+    }
+
     public function testMessageIdsExistInAllLanguages()
     {
         $messageIds = [];

+ 23 - 5
tst/JsonApiTest.php

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

+ 86 - 0
tst/MigrateForceOverwriteTest.php

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

+ 31 - 0
tst/MigrateTest.php

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

+ 115 - 0
tst/MigrateWriteFailureTest.php

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

+ 1 - 1
tst/RequestTest.php

@@ -20,7 +20,7 @@ class RequestTest extends TestCase
      */
     public function getRandomQueryChars()
     {
-        $queryChars     = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ=';
+        $queryChars     = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ=-_.!~*()';
         $queryCharCount = strlen($queryChars) - 1;
         $resultLength   = random_int(1, 10);
         $result         = '';

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor