]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #4972 from johnroyer/fix-typo-in-language-file
authorDan Brown <redacted>
Fri, 3 May 2024 18:16:23 +0000 (19:16 +0100)
committerGitHub <redacted>
Fri, 3 May 2024 18:16:23 +0000 (19:16 +0100)
remove space at the beginning of description

25 files changed:
.env.example.complete
app/Access/LdapService.php
app/Config/app.php
app/Config/exports.php [moved from app/Config/dompdf.php with 91% similarity]
app/Config/services.php
app/Config/snappy.php [deleted file]
app/Entities/Tools/PdfGenerator.php
app/Exceptions/PdfExportException.php [new file with mode: 0644]
composer.json
composer.lock
phpunit.xml
readme.md
resources/js/code/index.mjs
resources/js/components/code-editor.js
resources/js/markdown/codemirror.js
resources/js/wysiwyg/drop-paste-handling.js
resources/js/wysiwyg/fixes.js
resources/js/wysiwyg/plugin-codeeditor.js
resources/js/wysiwyg/toolbars.js
resources/sass/_components.scss
resources/sass/_content.scss
resources/sass/_tinymce.scss
tests/Auth/LdapTest.php
tests/Entity/ExportTest.php
tests/Unit/ConfigTest.php

index 1242968182a0e22b9a8d9b8fa20b60ddda9ec3df..1a4f421f0244a87dc0190292d9cc82ccaa7c9e28 100644 (file)
@@ -215,10 +215,11 @@ LDAP_SERVER=false
 LDAP_BASE_DN=false
 LDAP_DN=false
 LDAP_PASS=false
-LDAP_USER_FILTER=false
+LDAP_USER_FILTER="(&(uid={user}))"
 LDAP_VERSION=false
 LDAP_START_TLS=false
 LDAP_TLS_INSECURE=false
+LDAP_TLS_CA_CERT=false
 LDAP_ID_ATTRIBUTE=uid
 LDAP_EMAIL_ATTRIBUTE=mail
 LDAP_DISPLAY_NAME_ATTRIBUTE=cn
@@ -325,6 +326,14 @@ FILE_UPLOAD_SIZE_LIMIT=50
 # Can be 'a4' or 'letter'.
 EXPORT_PAGE_SIZE=a4
 
+# Export PDF Command
+# Set a command which can be used to convert a HTML file into a PDF file.
+# When false this will not be used.
+# String values represent the command to be called for conversion.
+# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
+# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
+EXPORT_PDF_COMMAND=false
+
 # Set path to wkhtmltopdf binary for PDF generation.
 # Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
 # When false, BookStack will attempt to find a wkhtmltopdf in the application
index 9d266763531685377e41d7d9775d61c9803b5383..e822b09a67cc6cc13d58ce9f754a88b48f13ef92 100644 (file)
@@ -209,6 +209,12 @@ class LdapService
             $this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
         }
 
+        // Configure any user-provided CA cert files for LDAP.
+        // This option works globally and must be set before a connection is created.
+        if ($this->config['tls_ca_cert']) {
+            $this->configureTlsCaCerts($this->config['tls_ca_cert']);
+        }
+
         $ldapHost = $this->parseServerString($this->config['server']);
         $ldapConnection = $this->ldap->connect($ldapHost);
 
@@ -223,7 +229,14 @@ class LdapService
 
         // Start and verify TLS if it's enabled
         if ($this->config['start_tls']) {
-            $started = $this->ldap->startTls($ldapConnection);
+            try {
+                $started = $this->ldap->startTls($ldapConnection);
+            } catch (\Exception $exception) {
+                $error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection);
+                ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail);
+                Log::info("LDAP STARTTLS failure: {$error} {$detail}");
+                throw new LdapException('Could not start TLS connection. Further details in the application log.');
+            }
             if (!$started) {
                 throw new LdapException('Could not start TLS connection');
             }
@@ -234,6 +247,33 @@ class LdapService
         return $this->ldapConnection;
     }
 
+    /**
+     * Configure TLS CA certs globally for ldap use.
+     * This will detect if the given path is a directory or file, and set the relevant
+     * LDAP TLS options appropriately otherwise throw an exception if no file/folder found.
+     *
+     * Note: When using a folder, certificates are expected to be correctly named by hash
+     * which can be done via the c_rehash utility.
+     *
+     * @throws LdapException
+     */
+    protected function configureTlsCaCerts(string $caCertPath): void
+    {
+        $errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location";
+        $path = realpath($caCertPath);
+        if ($path === false) {
+            throw new LdapException($errMessage);
+        }
+
+        if (is_dir($path)) {
+            $this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path);
+        } else if (is_file($path)) {
+            $this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path);
+        } else {
+            throw new LdapException($errMessage);
+        }
+    }
+
     /**
      * Parse an LDAP server string and return the host suitable for a connection.
      * Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
@@ -249,13 +289,18 @@ class LdapService
 
     /**
      * Build a filter string by injecting common variables.
+     * Both "${var}" and "{var}" style placeholders are supported.
+     * Dollar based are old format but supported for compatibility.
      */
     protected function buildFilter(string $filterString, array $attrs): string
     {
         $newAttrs = [];
         foreach ($attrs as $key => $attrText) {
-            $newKey = '${' . $key . '}';
-            $newAttrs[$newKey] = $this->ldap->escape($attrText);
+            $escapedText = $this->ldap->escape($attrText);
+            $oldVarKey = '${' . $key . '}';
+            $newVarKey = '{' . $key . '}';
+            $newAttrs[$oldVarKey] = $escapedText;
+            $newAttrs[$newVarKey] = $escapedText;
         }
 
         return strtr($filterString, $newAttrs);
index dda787f3f99cbc534d392cea1a579eca3ebb86e5..b96d0bdb7885215ff65ac665b77591f37a23745d 100644 (file)
@@ -116,8 +116,6 @@ return [
     // Application Service Providers
     'providers' => ServiceProvider::defaultProviders()->merge([
         // Third party service providers
-        Barryvdh\DomPDF\ServiceProvider::class,
-        Barryvdh\Snappy\ServiceProvider::class,
         SocialiteProviders\Manager\ServiceProvider::class,
 
         // BookStack custom service providers
similarity index 91%
rename from app/Config/dompdf.php
rename to app/Config/exports.php
index 09dd91bcc3ca762b3a340cefb579eaa8faa6c4a2..88dc08cba354ca0922631b6d0e99bebb770a2558 100644 (file)
@@ -1,23 +1,45 @@
 <?php
 
 /**
- * DOMPDF configuration options.
+ * Export configuration options.
  *
  * Changes to these config files are not supported by BookStack and may break upon updates.
  * Configuration should be altered via the `.env` file or environment variables.
  * Do not edit this file unless you're happy to maintain any changes yourself.
  */
 
+$snappyPaperSizeMap = [
+    'a4'     => 'A4',
+    'letter' => 'Letter',
+];
+
 $dompdfPaperSizeMap = [
     'a4'     => 'a4',
     'letter' => 'letter',
 ];
 
+$exportPageSize = env('EXPORT_PAGE_SIZE', 'a4');
+
 return [
 
-    'show_warnings' => false,   // Throw an Exception on warnings from dompdf
+    // Set a command which can be used to convert a HTML file into a PDF file.
+    // When false this will not be used.
+    // String values represent the command to be called for conversion.
+    // Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
+    // Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
+    'pdf_command' => env('EXPORT_PDF_COMMAND', false),
 
-    'options'       => [
+    // 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support.
+    'snappy' => [
+        'pdf_binary' => env('WKHTMLTOPDF', false),
+        'options' => [
+            'print-media-type' => true,
+            'outline'   => true,
+            'page-size' => $snappyPaperSizeMap[$exportPageSize] ?? 'A4',
+        ],
+    ],
+
+    'dompdf' => [
         /**
          * The location of the DOMPDF font directory.
          *
@@ -101,7 +123,7 @@ return [
         /**
          * Whether to enable font subsetting or not.
          */
-        'enable_fontsubsetting' => false,
+        'enable_font_subsetting' => false,
 
         /**
          * The PDF rendering backend to use.
@@ -165,7 +187,7 @@ return [
          *
          * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
          */
-        'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
+        'default_paper_size' => $dompdfPaperSizeMap[$exportPageSize] ?? 'a4',
 
         /**
          * The default paper orientation.
@@ -268,15 +290,6 @@ return [
          */
         'font_height_ratio' => 1.1,
 
-        /**
-         * Enable CSS float.
-         *
-         * Allows people to disabled CSS float support
-         *
-         * @var bool
-         */
-        'enable_css_float' => true,
-
         /**
          * Use the HTML5 Lib parser.
          *
@@ -286,5 +299,4 @@ return [
          */
         'enable_html5_parser' => true,
     ],
-
 ];
index a035f10569500d5c05c552c351e7c5d512117cd3..d73458231506cb56cc16ec81d4329709b14dbf95 100644 (file)
@@ -123,7 +123,7 @@ return [
         'dn'                     => env('LDAP_DN', false),
         'pass'                   => env('LDAP_PASS', false),
         'base_dn'                => env('LDAP_BASE_DN', false),
-        'user_filter'            => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
+        'user_filter'            => env('LDAP_USER_FILTER', '(&(uid={user}))'),
         'version'                => env('LDAP_VERSION', false),
         'id_attribute'           => env('LDAP_ID_ATTRIBUTE', 'uid'),
         'email_attribute'        => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
@@ -133,6 +133,7 @@ return [
         'group_attribute'        => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
         'remove_from_groups'     => env('LDAP_REMOVE_FROM_GROUPS', false),
         'tls_insecure'           => env('LDAP_TLS_INSECURE', false),
+        'tls_ca_cert'            => env('LDAP_TLS_CA_CERT', false),
         'start_tls'              => env('LDAP_START_TLS', false),
         'thumbnail_attribute'    => env('LDAP_THUMBNAIL_ATTRIBUTE', null),
     ],
diff --git a/app/Config/snappy.php b/app/Config/snappy.php
deleted file mode 100644 (file)
index a87ce80..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-/**
- * SnappyPDF configuration options.
- *
- * Changes to these config files are not supported by BookStack and may break upon updates.
- * Configuration should be altered via the `.env` file or environment variables.
- * Do not edit this file unless you're happy to maintain any changes yourself.
- */
-
-$snappyPaperSizeMap = [
-    'a4'     => 'A4',
-    'letter' => 'Letter',
-];
-
-return [
-    'pdf' => [
-        'enabled' => true,
-        'binary'  => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
-        'timeout' => false,
-        'options' => [
-            'outline'   => true,
-            'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
-        ],
-        'env'     => [],
-    ],
-    'image' => [
-        'enabled' => false,
-        'binary'  => '/usr/local/bin/wkhtmltoimage',
-        'timeout' => false,
-        'options' => [],
-        'env'     => [],
-    ],
-];
index d0c9158a91c3a1dcd2a638461491170276097ff5..7c6dfaa6e8b2e563ea18742b42924726127e7eff 100644 (file)
@@ -2,27 +2,28 @@
 
 namespace BookStack\Entities\Tools;
 
-use Barryvdh\DomPDF\Facade\Pdf as DomPDF;
-use Barryvdh\Snappy\Facades\SnappyPdf;
+use BookStack\Exceptions\PdfExportException;
+use Knp\Snappy\Pdf as SnappyPdf;
+use Dompdf\Dompdf;
+use Symfony\Component\Process\Process;
 
 class PdfGenerator
 {
     const ENGINE_DOMPDF = 'dompdf';
     const ENGINE_WKHTML = 'wkhtml';
+    const ENGINE_COMMAND = 'command';
 
     /**
      * Generate PDF content from the given HTML content.
+     * @throws PdfExportException
      */
     public function fromHtml(string $html): string
     {
-        if ($this->getActiveEngine() === self::ENGINE_WKHTML) {
-            $pdf = SnappyPDF::loadHTML($html);
-            $pdf->setOption('print-media-type', true);
-        } else {
-            $pdf = DomPDF::loadHTML($html);
-        }
-
-        return $pdf->output();
+        return match ($this->getActiveEngine()) {
+            self::ENGINE_COMMAND => $this->renderUsingCommand($html),
+            self::ENGINE_WKHTML => $this->renderUsingWkhtml($html),
+            default => $this->renderUsingDomPdf($html)
+        };
     }
 
     /**
@@ -31,8 +32,101 @@ class PdfGenerator
      */
     public function getActiveEngine(): string
     {
-        $useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
+        if (config('exports.pdf_command')) {
+            return self::ENGINE_COMMAND;
+        }
+
+        if ($this->getWkhtmlBinaryPath() && config('app.allow_untrusted_server_fetching') === true) {
+            return self::ENGINE_WKHTML;
+        }
+
+        return self::ENGINE_DOMPDF;
+    }
+
+    protected function getWkhtmlBinaryPath(): string
+    {
+        $wkhtmlBinaryPath = config('exports.snappy.pdf_binary');
+        if (file_exists(base_path('wkhtmltopdf'))) {
+            $wkhtmlBinaryPath = base_path('wkhtmltopdf');
+        }
+
+        return $wkhtmlBinaryPath ?: '';
+    }
+
+    protected function renderUsingDomPdf(string $html): string
+    {
+        $options = config('exports.dompdf');
+        $domPdf = new Dompdf($options);
+        $domPdf->setBasePath(base_path('public'));
+
+        $domPdf->loadHTML($this->convertEntities($html));
+        $domPdf->render();
+
+        return (string) $domPdf->output();
+    }
+
+    /**
+     * @throws PdfExportException
+     */
+    protected function renderUsingCommand(string $html): string
+    {
+        $command = config('exports.pdf_command');
+        $inputHtml = tempnam(sys_get_temp_dir(), 'bs-pdfgen-html-');
+        $outputPdf = tempnam(sys_get_temp_dir(), 'bs-pdfgen-output-');
 
-        return $useWKHTML ? self::ENGINE_WKHTML : self::ENGINE_DOMPDF;
+        $replacementsByPlaceholder = [
+            '{input_html_path}' => $inputHtml,
+            '{output_pdf_path}' => $outputPdf,
+        ];
+
+        foreach ($replacementsByPlaceholder as $placeholder => $replacement) {
+            $command = str_replace($placeholder, escapeshellarg($replacement), $command);
+        }
+
+        file_put_contents($inputHtml, $html);
+
+        $process = Process::fromShellCommandline($command);
+        $process->setTimeout(15);
+        $process->run();
+
+        if (!$process->isSuccessful()) {
+            throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}");
+        }
+
+        $pdfContents = file_get_contents($outputPdf);
+        unlink($outputPdf);
+
+        if ($pdfContents === false) {
+            throw new PdfExportException("PDF Export via command failed, unable to read PDF output file");
+        } else if (empty($pdfContents)) {
+            throw new PdfExportException("PDF Export via command failed, PDF output file is empty");
+        }
+
+        return $pdfContents;
+    }
+
+    protected function renderUsingWkhtml(string $html): string
+    {
+        $snappy = new SnappyPdf($this->getWkhtmlBinaryPath());
+        $options = config('exports.snappy.options');
+        return $snappy->getOutputFromHtml($html, $options);
+    }
+
+    /**
+     * Taken from https://github.com/barryvdh/laravel-dompdf/blob/v2.1.1/src/PDF.php
+     * Copyright (c) 2021 barryvdh, MIT License
+     * https://github.com/barryvdh/laravel-dompdf/blob/v2.1.1/LICENSE
+     */
+    protected function convertEntities(string $subject): string
+    {
+        $entities = [
+            '€' => '&euro;',
+            '£' => '&pound;',
+        ];
+
+        foreach ($entities as $search => $replace) {
+            $subject = str_replace($search, $replace, $subject);
+        }
+        return $subject;
     }
 }
diff --git a/app/Exceptions/PdfExportException.php b/app/Exceptions/PdfExportException.php
new file mode 100644 (file)
index 0000000..beeda81
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+class PdfExportException extends \Exception
+{
+}
index b22c7b44de9ab52ed65291f40a249d6aa9be496c..b90ab224eaa5b63b19d63c0614cd48b222f8e205 100644 (file)
         "ext-mbstring": "*",
         "ext-xml": "*",
         "bacon/bacon-qr-code": "^2.0",
-        "barryvdh/laravel-dompdf": "^2.0",
-        "barryvdh/laravel-snappy": "^1.0",
         "doctrine/dbal": "^3.5",
+        "dompdf/dompdf": "^2.0",
         "guzzlehttp/guzzle": "^7.4",
         "intervention/image": "^3.5",
+        "knplabs/knp-snappy": "^1.5",
         "laravel/framework": "^10.10",
         "laravel/socialite": "^5.10",
         "laravel/tinker": "^2.8",
index 24c2215dd5f730d7e022104e29a84029b6cc55ab..ad5648d6bc3889a94d13e1dccef0c7a46e6ffc71 100644 (file)
@@ -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": "ccfc07d0ecc580962915a0457f0466a7",
+    "content-hash": "97259e40ffe5518cfcdf1e32eacbb175",
     "packages": [
         {
             "name": "aws/aws-crt-php",
             },
             "time": "2022-12-07T17:46:57+00:00"
         },
-        {
-            "name": "barryvdh/laravel-dompdf",
-            "version": "v2.1.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/barryvdh/laravel-dompdf.git",
-                "reference": "cb37868365f9b937039d316727a1fced1e87b31c"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/cb37868365f9b937039d316727a1fced1e87b31c",
-                "reference": "cb37868365f9b937039d316727a1fced1e87b31c",
-                "shasum": ""
-            },
-            "require": {
-                "dompdf/dompdf": "^2.0.3",
-                "illuminate/support": "^6|^7|^8|^9|^10|^11",
-                "php": "^7.2 || ^8.0"
-            },
-            "require-dev": {
-                "larastan/larastan": "^1.0|^2.7.0",
-                "orchestra/testbench": "^4|^5|^6|^7|^8|^9",
-                "phpro/grumphp": "^1 || ^2.5",
-                "squizlabs/php_codesniffer": "^3.5"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0-dev"
-                },
-                "laravel": {
-                    "providers": [
-                        "Barryvdh\\DomPDF\\ServiceProvider"
-                    ],
-                    "aliases": {
-                        "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf",
-                        "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf"
-                    }
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Barryvdh\\DomPDF\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Barry vd. Heuvel",
-                    "email": "[email protected]"
-                }
-            ],
-            "description": "A DOMPDF Wrapper for Laravel",
-            "keywords": [
-                "dompdf",
-                "laravel",
-                "pdf"
-            ],
-            "support": {
-                "issues": "https://github.com/barryvdh/laravel-dompdf/issues",
-                "source": "https://github.com/barryvdh/laravel-dompdf/tree/v2.1.1"
-            },
-            "funding": [
-                {
-                    "url": "https://fruitcake.nl",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/barryvdh",
-                    "type": "github"
-                }
-            ],
-            "time": "2024-03-15T12:48:39+00:00"
-        },
-        {
-            "name": "barryvdh/laravel-snappy",
-            "version": "v1.0.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/barryvdh/laravel-snappy.git",
-                "reference": "716dcb6db24de4ce8e6ae5941cfab152af337ea0"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/barryvdh/laravel-snappy/zipball/716dcb6db24de4ce8e6ae5941cfab152af337ea0",
-                "reference": "716dcb6db24de4ce8e6ae5941cfab152af337ea0",
-                "shasum": ""
-            },
-            "require": {
-                "illuminate/filesystem": "^9|^10|^11.0",
-                "illuminate/support": "^9|^10|^11.0",
-                "knplabs/knp-snappy": "^1.4.4",
-                "php": ">=7.2"
-            },
-            "require-dev": {
-                "orchestra/testbench": "^7|^8|^9.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0-dev"
-                },
-                "laravel": {
-                    "providers": [
-                        "Barryvdh\\Snappy\\ServiceProvider"
-                    ],
-                    "aliases": {
-                        "PDF": "Barryvdh\\Snappy\\Facades\\SnappyPdf",
-                        "SnappyImage": "Barryvdh\\Snappy\\Facades\\SnappyImage"
-                    }
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Barryvdh\\Snappy\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Barry vd. Heuvel",
-                    "email": "[email protected]"
-                }
-            ],
-            "description": "Snappy PDF/Image for Laravel",
-            "keywords": [
-                "image",
-                "laravel",
-                "pdf",
-                "snappy",
-                "wkhtmltoimage",
-                "wkhtmltopdf"
-            ],
-            "support": {
-                "issues": "https://github.com/barryvdh/laravel-snappy/issues",
-                "source": "https://github.com/barryvdh/laravel-snappy/tree/v1.0.3"
-            },
-            "funding": [
-                {
-                    "url": "https://fruitcake.nl",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/barryvdh",
-                    "type": "github"
-                }
-            ],
-            "time": "2024-03-09T19:20:39+00:00"
-        },
         {
             "name": "brick/math",
             "version": "0.11.0",
         },
         {
             "name": "dompdf/dompdf",
-            "version": "v2.0.4",
+            "version": "v2.0.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/dompdf/dompdf.git",
-                "reference": "093f2d9739cec57428e39ddadedfd4f3ae862c0f"
+                "reference": "ab0123052b42ad0867348f25df8c228f1ece8f14"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/dompdf/dompdf/zipball/093f2d9739cec57428e39ddadedfd4f3ae862c0f",
-                "reference": "093f2d9739cec57428e39ddadedfd4f3ae862c0f",
+                "url": "https://api.github.com/repos/dompdf/dompdf/zipball/ab0123052b42ad0867348f25df8c228f1ece8f14",
+                "reference": "ab0123052b42ad0867348f25df8c228f1ece8f14",
                 "shasum": ""
             },
             "require": {
                 "ext-mbstring": "*",
                 "masterminds/html5": "^2.0",
                 "phenx/php-font-lib": ">=0.5.4 <1.0.0",
-                "phenx/php-svg-lib": ">=0.3.3 <1.0.0",
+                "phenx/php-svg-lib": ">=0.5.2 <1.0.0",
                 "php": "^7.1 || ^8.0"
             },
             "require-dev": {
             "homepage": "https://github.com/dompdf/dompdf",
             "support": {
                 "issues": "https://github.com/dompdf/dompdf/issues",
-                "source": "https://github.com/dompdf/dompdf/tree/v2.0.4"
+                "source": "https://github.com/dompdf/dompdf/tree/v2.0.7"
             },
-            "time": "2023-12-12T20:19:39+00:00"
+            "time": "2024-04-15T12:40:33+00:00"
         },
         {
             "name": "dragonmantank/cron-expression",
         },
         {
             "name": "phenx/php-svg-lib",
-            "version": "0.5.3",
+            "version": "0.5.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/dompdf/php-svg-lib.git",
-                "reference": "0e46722c154726a5f9ac218197ccc28adba16fcf"
+                "reference": "46b25da81613a9cf43c83b2a8c2c1bdab27df691"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/0e46722c154726a5f9ac218197ccc28adba16fcf",
-                "reference": "0e46722c154726a5f9ac218197ccc28adba16fcf",
+                "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/46b25da81613a9cf43c83b2a8c2c1bdab27df691",
+                "reference": "46b25da81613a9cf43c83b2a8c2c1bdab27df691",
                 "shasum": ""
             },
             "require": {
             "homepage": "https://github.com/PhenX/php-svg-lib",
             "support": {
                 "issues": "https://github.com/dompdf/php-svg-lib/issues",
-                "source": "https://github.com/dompdf/php-svg-lib/tree/0.5.3"
+                "source": "https://github.com/dompdf/php-svg-lib/tree/0.5.4"
             },
-            "time": "2024-02-23T20:39:24+00:00"
+            "time": "2024-04-08T12:52:34+00:00"
         },
         {
             "name": "phpoption/phpoption",
index a9e97f0c78a56cc33902eebf0d56972bbaeb7635..21f17685b5b63aacd448fcbfa898ca9d16b6ffaa 100644 (file)
@@ -51,6 +51,7 @@
     <server name="LOG_FAILED_LOGIN_MESSAGE" value=""/>
     <server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/>
     <server name="WKHTMLTOPDF" value="false"/>
+    <server name="EXPORT_PDF_COMMAND" value="false"/>
     <server name="APP_DEFAULT_DARK_MODE" value="false"/>
     <server name="IP_ADDRESS_PRECISION" value="4"/>
   </php>
index 17e1a05f6593eb1e43042fcce36859fe61bf4b22..c46e1641f19b8c9399f8669dfbd6e694e6251886 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -142,8 +142,7 @@ Note: This is not an exhaustive list of all libraries and projects that would be
 * [Google Material Icons](https://github.com/google/material-design-icons) - _[Apache-2.0](https://github.com/google/material-design-icons/blob/master/LICENSE)_
 * [markdown-it](https://github.com/markdown-it/markdown-it) and [markdown-it-task-lists](https://github.com/revin/markdown-it-task-lists) - _[MIT](https://github.com/markdown-it/markdown-it/blob/master/LICENSE) and [ISC](https://github.com/revin/markdown-it-task-lists/blob/master/LICENSE)_
 * [Dompdf](https://github.com/dompdf/dompdf) - _[LGPL v2.1](https://github.com/dompdf/dompdf/blob/master/LICENSE.LGPL)_
-* [BarryVD/Dompdf](https://github.com/barryvdh/laravel-dompdf) - _[MIT](https://github.com/barryvdh/laravel-dompdf/blob/master/LICENSE)_
-* [BarryVD/Snappy (WKHTML2PDF)](https://github.com/barryvdh/laravel-snappy) - _[MIT](https://github.com/barryvdh/laravel-snappy/blob/master/LICENSE)_
+* [KnpLabs/snappy](https://github.com/KnpLabs/snappy) - _[MIT](https://github.com/KnpLabs/snappy/blob/master/LICENSE)_
 * [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html) - _[LGPL v3.0](https://github.com/wkhtmltopdf/wkhtmltopdf/blob/master/LICENSE)_
 * [diagrams.net](https://github.com/jgraph/drawio) - _[Embedded Version Terms](https://www.diagrams.net/trust/) / [Source Project - Apache-2.0](https://github.com/jgraph/drawio/blob/dev/LICENSE)_
 * [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml) - _[MIT](https://github.com/onelogin/php-saml/blob/master/LICENSE)_
index ab31e3f74e43d9ddf2334a9f5f7e183d906d1851..d2ea12a4c93cd6d8593cca9068e8801f9c55248d 100644 (file)
@@ -38,6 +38,23 @@ function addCopyIcon(editorView) {
     });
 }
 
+/**
+ * @param {HTMLElement} codeElem
+ * @returns {String}
+ */
+function getDirectionFromCodeBlock(codeElem) {
+    let dir = '';
+    const innerCodeElem = codeElem.querySelector('code');
+
+    if (innerCodeElem && innerCodeElem.hasAttribute('dir')) {
+        dir = innerCodeElem.getAttribute('dir');
+    } else if (codeElem.hasAttribute('dir')) {
+        dir = codeElem.getAttribute('dir');
+    }
+
+    return dir;
+}
+
 /**
  * Add code highlighting to a single element.
  * @param {HTMLElement} elem
@@ -48,16 +65,14 @@ function highlightElem(elem) {
     const content = elem.textContent.trimEnd();
 
     let langName = '';
-    let innerCodeDirection = '';
     if (innerCodeElem !== null) {
         langName = innerCodeElem.className.replace('language-', '');
-        innerCodeDirection = innerCodeElem.getAttribute('dir');
     }
 
     const wrapper = document.createElement('div');
     elem.parentNode.insertBefore(wrapper, elem);
 
-    const direction = innerCodeDirection || elem.getAttribute('dir') || '';
+    const direction = getDirectionFromCodeBlock(elem);
     if (direction) {
         wrapper.setAttribute('dir', direction);
     }
index 1c68c2048d2b695544e336c7291cb2a46350f12c..091c3483f4d6bd1ecffabb7f52f3f0b17933abfa 100644 (file)
@@ -129,7 +129,7 @@ export class CodeEditor extends Component {
         this.hide();
     }
 
-    async open(code, language, saveCallback, cancelCallback) {
+    async open(code, language, direction, saveCallback, cancelCallback) {
         this.languageInput.value = language;
         this.saveCallback = saveCallback;
         this.cancelCallback = cancelCallback;
@@ -137,6 +137,7 @@ export class CodeEditor extends Component {
         await this.show();
         this.languageInputChange(language);
         this.editor.setContent(code);
+        this.setDirection(direction);
     }
 
     async show() {
@@ -156,6 +157,15 @@ export class CodeEditor extends Component {
         });
     }
 
+    setDirection(direction) {
+        const target = this.editorInput.parentElement;
+        if (direction) {
+            target.setAttribute('dir', direction);
+        } else {
+            target.removeAttribute('dir');
+        }
+    }
+
     hide() {
         this.getPopup().hide();
         this.addHistory();
index 9d54c19d754b52b6489bc9aad5bdb7299f4d8542..2ea944865c12d90768956594364eb8901960eb56 100644 (file)
@@ -44,6 +44,10 @@ export async function init(editor) {
                 editor.actions.insertClipboardImages(clipboardImages, event.pageX, event.pageY);
             }
         },
+        // Handle dragover event to allow as drop-target in chrome
+        dragover: event => {
+            event.preventDefault();
+        },
         // Handle image paste
         paste: event => {
             const clipboard = new Clipboard(event.clipboardData || event.dataTransfer);
index 9668692c81d1e162cf6f73d954c6cd307bd29735..172ad970f0de8a5e51f495a012b88bf157107b85 100644 (file)
@@ -149,11 +149,26 @@ function drop(editor, options, event) {
     wrap = null;
 }
 
+/**
+ * @param {Editor} editor
+ * @param {DragEvent} event
+ */
+function dragOver(editor, event) {
+    // This custom handling essentially emulates the default TinyMCE behaviour while allowing us
+    // to specifically call preventDefault on the event to allow the drop of custom elements.
+    event.preventDefault();
+    editor.focus();
+    const rangeUtils = window.tinymce.dom.RangeUtils;
+    const range = rangeUtils.getCaretRangeFromPoint(event.clientX ?? 0, event.clientY ?? 0, editor.getDoc());
+    editor.selection.setRng(range);
+}
+
 /**
  * @param {Editor} editor
  * @param {WysiwygConfigOptions} options
  */
 export function listenForDragAndPaste(editor, options) {
+    editor.on('dragover', event => dragOver(editor, event));
     editor.on('dragstart', () => dragStart(editor));
     editor.on('drop', event => drop(editor, options, event));
     editor.on('paste', event => paste(editor, options, event));
index 7f87d4378c6c9afc3fde46c87d69122b96d84b2f..61cace66052ea53cd559738b10198e1b963b6d77 100644 (file)
@@ -29,12 +29,13 @@ export function handleEmbedAlignmentChanges(editor) {
 
     editor.on('FormatApply', event => {
         const isAlignment = event.format.startsWith('align');
-        if (!event.node || !event.node.matches('.mce-preview-object')) {
+        const isElement = event.node instanceof editor.dom.doc.defaultView.HTMLElement;
+        if (!isElement || !isAlignment || !event.node.matches('.mce-preview-object')) {
             return;
         }
 
         const realTarget = event.node.querySelector('iframe, video');
-        if (isAlignment && realTarget) {
+        if (realTarget) {
             const className = (editor.formatter.get(event.format)[0]?.classes || [])[0];
             const toAdd = !realTarget.classList.contains(className);
 
@@ -94,10 +95,12 @@ export function handleTableCellRangeEvents(editor) {
     // are selected. Here we watch for clear formatting events, so some manual
     // cleanup can be performed.
     const attrsToRemove = ['class', 'style', 'width', 'height'];
-    editor.on('FormatRemove', () => {
-        for (const cell of selectedCells) {
-            for (const attr of attrsToRemove) {
-                cell.removeAttribute(attr);
+    editor.on('ExecCommand', event => {
+        if (event.command === 'RemoveFormat') {
+            for (const cell of selectedCells) {
+                for (const attr of attrsToRemove) {
+                    cell.removeAttribute(attr);
+                }
             }
         }
     });
index f86760214e1ed93d124b6a9f777463f1bb1525e5..c01a7eca0298c857b34ee2519a4e745e62e08ea5 100644 (file)
@@ -6,13 +6,14 @@ function elemIsCodeBlock(elem) {
  * @param {Editor} editor
  * @param {String} code
  * @param {String} language
+ * @param {String} direction
  * @param {function(string, string)} callback (Receives (code: string,language: string)
  */
-function showPopup(editor, code, language, callback) {
+function showPopup(editor, code, language, direction, callback) {
     /** @var {CodeEditor} codeEditor * */
     const codeEditor = window.$components.first('code-editor');
     const bookMark = editor.selection.getBookmark();
-    codeEditor.open(code, language, (newCode, newLang) => {
+    codeEditor.open(code, language, direction, (newCode, newLang) => {
         callback(newCode, newLang);
         editor.focus();
         editor.selection.moveToBookmark(bookMark);
@@ -27,7 +28,8 @@ function showPopup(editor, code, language, callback) {
  * @param {CodeBlockElement} codeBlock
  */
 function showPopupForCodeBlock(editor, codeBlock) {
-    showPopup(editor, codeBlock.getContent(), codeBlock.getLanguage(), (newCode, newLang) => {
+    const direction = codeBlock.getAttribute('dir') || '';
+    showPopup(editor, codeBlock.getContent(), codeBlock.getLanguage(), direction, (newCode, newLang) => {
         codeBlock.setContent(newCode, newLang);
     });
 }
@@ -179,13 +181,17 @@ function register(editor) {
             showPopupForCodeBlock(editor, selectedNode);
         } else {
             const textContent = editor.selection.getContent({format: 'text'});
-            showPopup(editor, textContent, '', (newCode, newLang) => {
+            const direction = document.dir === 'rtl' ? 'ltr' : '';
+            showPopup(editor, textContent, '', direction, (newCode, newLang) => {
                 const pre = doc.createElement('pre');
                 const code = doc.createElement('code');
                 code.classList.add(`language-${newLang}`);
                 code.innerText = newCode;
-                pre.append(code);
+                if (direction) {
+                    pre.setAttribute('dir', direction);
+                }
 
+                pre.append(code);
                 editor.insertContent(pre.outerHTML);
             });
         }
@@ -205,7 +211,8 @@ function register(editor) {
                     contenteditable: 'false',
                 });
 
-                const direction = el.attr('dir');
+                const childCodeBlock = el.children().filter(child => child.name === 'code')[0] || null;
+                const direction = el.attr('dir') || (childCodeBlock && childCodeBlock.attr('dir')) || '';
                 if (direction) {
                     wrapper.attr('dir', direction);
                 }
index bd1ff1b6d94e7d4ecabc778e3090a1d39ff0f472..897aa9f06ef2facbded27b2f64b2dfdfa2b3fbc4 100644 (file)
@@ -60,7 +60,7 @@ function registerLinkContextToolbar(editor) {
 function registerImageContextToolbar(editor) {
     editor.ui.registry.addContextToolbar('imagecontexttoolbar', {
         predicate(node) {
-            return node.closest('img') !== null;
+            return node.closest('img') !== null && !node.hasAttribute('data-mce-object');
         },
         position: 'node',
         scope: 'node',
@@ -68,6 +68,20 @@ function registerImageContextToolbar(editor) {
     });
 }
 
+/**
+ * @param {Editor} editor
+ */
+function registerObjectContextToolbar(editor) {
+    editor.ui.registry.addContextToolbar('objectcontexttoolbar', {
+        predicate(node) {
+            return node.closest('img') !== null && node.hasAttribute('data-mce-object');
+        },
+        position: 'node',
+        scope: 'node',
+        items: 'media',
+    });
+}
+
 /**
  * @param {Editor} editor
  */
@@ -75,4 +89,5 @@ export function registerAdditionalToolbars(editor) {
     registerPrimaryToolbarGroups(editor);
     registerLinkContextToolbar(editor);
     registerImageContextToolbar(editor);
+    registerObjectContextToolbar(editor);
 }
index ae899357c98f115ba27331e7ac2788870436d9e8..fc4ddeba4ae56d4805f7df3a70c6afd7aad38298 100644 (file)
   flex: 0;
   .popup-title {
     color: #FFF;
-    margin-right: auto;
+    margin-inline-end: auto;
     padding: 8px $-m;
   }
   &.flex-container-row {
index f844993644c1ea6bafe08595ce1a634f60722003..3aa4ac653d92da1db9eb5be4be90dd7e7a7ceeb1 100644 (file)
     margin-left: auto;
     margin-right: auto;
   }
-  img {
-    max-width: 100%;
-    height:auto;
-  }
   h1, h2, h3, h4, h5, h6, pre {
     clear: left;
   }
   }
 }
 
+// This is seperated out so we can target it out-of-editor by default
+// and use advanced (:not) syntax, not supported by things like PDF gen,
+// to target in-editor scenarios to handle edge-case of TinyMCE using an
+// image for data placeholders where we'd want height attributes to take effect.
+body .page-content img,
+.page-content img:not([data-mce-object]) {
+  max-width: 100%;
+  height:auto;
+}
+
 /**
  * Callouts
  */
index 29843e4246fdfe49aa3c2c46e82a7d22504138d6..95294cdf2c0885636b12ece48f10d2ad865616fb 100644 (file)
   display: block;
 }
 
-// In editor line height override
-.page-content.mce-content-body p {
-  line-height: 1.6;
-}
-
 // Pad out bottom of editor
 body.page-content.mce-content-body  {
   padding-bottom: 5rem;
index 34900ce6f70a1d7356f6f45d822a207c73e48870..3f80f00f41e4abf454b2121abde1005aa3adef90 100644 (file)
@@ -4,6 +4,7 @@ namespace Tests\Auth;
 
 use BookStack\Access\Ldap;
 use BookStack\Access\LdapService;
+use BookStack\Exceptions\LdapException;
 use BookStack\Users\Models\Role;
 use BookStack\Users\Models\User;
 use Illuminate\Testing\TestResponse;
@@ -32,9 +33,10 @@ class LdapTest extends TestCase
             'services.ldap.id_attribute'           => 'uid',
             'services.ldap.user_to_groups'         => false,
             'services.ldap.version'                => '3',
-            'services.ldap.user_filter'            => '(&(uid=${user}))',
+            'services.ldap.user_filter'            => '(&(uid={user}))',
             'services.ldap.follow_referrals'       => false,
             'services.ldap.tls_insecure'           => false,
+            'services.ldap.tls_ca_cert'            => false,
             'services.ldap.thumbnail_attribute'    => null,
         ]);
         $this->mockLdap = $this->mock(Ldap::class);
@@ -178,6 +180,38 @@ class LdapTest extends TestCase
         $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']);
     }
 
+    public function test_user_filter_default_placeholder_format()
+    {
+        config()->set('services.ldap.user_filter', '(&(uid={user}))');
+        $this->mockUser->name = 'barryldapuser';
+        $expectedFilter = '(&(uid=\62\61\72\72\79\6c\64\61\70\75\73\65\72))';
+
+        $this->commonLdapMocks(1, 1, 1, 1, 1);
+        $this->mockLdap->shouldReceive('searchAndGetEntries')
+            ->once()
+            ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array'))
+            ->andReturn(['count' => 0, 0 => []]);
+
+        $resp = $this->mockUserLogin();
+        $resp->assertRedirect('/login');
+    }
+
+    public function test_user_filter_old_placeholder_format()
+    {
+        config()->set('services.ldap.user_filter', '(&(username=${user}))');
+        $this->mockUser->name = 'barryldapuser';
+        $expectedFilter = '(&(username=\62\61\72\72\79\6c\64\61\70\75\73\65\72))';
+
+        $this->commonLdapMocks(1, 1, 1, 1, 1);
+        $this->mockLdap->shouldReceive('searchAndGetEntries')
+            ->once()
+            ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array'))
+            ->andReturn(['count' => 0, 0 => []]);
+
+        $resp = $this->mockUserLogin();
+        $resp->assertRedirect('/login');
+    }
+
     public function test_initial_incorrect_credentials()
     {
         $this->commonLdapMocks(1, 1, 1, 0, 1);
@@ -767,4 +801,34 @@ EBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=')],
         $this->assertNotNull($user->avatar);
         $this->assertEquals('8c90748342f19b195b9c6b4eff742ded', md5_file(public_path($user->avatar->path)));
     }
+
+    public function test_tls_ca_cert_option_throws_if_set_to_invalid_location()
+    {
+        $path = 'non_found_' . time();
+        config()->set(['services.ldap.tls_ca_cert' => $path]);
+
+        $this->commonLdapMocks(0, 0, 0, 0, 0);
+
+        $this->assertThrows(function () {
+            $this->withoutExceptionHandling()->mockUserLogin();
+        }, LdapException::class, "Provided path [{$path}] for LDAP TLS CA certs could not be resolved to an existing location");
+    }
+
+    public function test_tls_ca_cert_option_used_if_set_to_a_folder()
+    {
+        $path = $this->files->testFilePath('');
+        config()->set(['services.ldap.tls_ca_cert' => $path]);
+
+        $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTDIR, rtrim($path, '/'))->andReturn(true);
+        $this->runFailedAuthLogin();
+    }
+
+    public function test_tls_ca_cert_option_used_if_set_to_a_file()
+    {
+        $path = $this->files->testFilePath('test-file.txt');
+        config()->set(['services.ldap.tls_ca_cert' => $path]);
+
+        $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTFILE, $path)->andReturn(true);
+        $this->runFailedAuthLogin();
+    }
 }
index eedcb672c998da7b53a26f76e7e59ae7fe6885e0..040f69013a5f1bee44b48759f10a13f644410c36 100644 (file)
@@ -6,8 +6,8 @@ use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Tools\PdfGenerator;
+use BookStack\Exceptions\PdfExportException;
 use Illuminate\Support\Facades\Storage;
-use Illuminate\Support\Str;
 use Tests\TestCase;
 
 class ExportTest extends TestCase
@@ -483,7 +483,7 @@ class ExportTest extends TestCase
     {
         $page = $this->entities->page();
 
-        config()->set('snappy.pdf.binary', '/abc123');
+        config()->set('exports.snappy.pdf_binary', '/abc123');
         config()->set('app.allow_untrusted_server_fetching', false);
 
         $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
@@ -494,6 +494,41 @@ class ExportTest extends TestCase
         $resp->assertStatus(500); // Bad response indicates wkhtml usage
     }
 
+    public function test_pdf_command_option_used_if_set()
+    {
+        $page = $this->entities->page();
+        $command = 'cp {input_html_path} {output_pdf_path}';
+        config()->set('exports.pdf_command', $command);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
+        $download = $resp->getContent();
+
+        $this->assertStringContainsString(e($page->name), $download);
+        $this->assertStringContainsString('<html lang=', $download);
+    }
+
+    public function test_pdf_command_option_errors_if_output_path_not_written_to()
+    {
+        $page = $this->entities->page();
+        $command = 'echo "hi"';
+        config()->set('exports.pdf_command', $command);
+
+        $this->assertThrows(function () use ($page) {
+            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
+        }, PdfExportException::class);
+    }
+
+    public function test_pdf_command_option_errors_if_command_returns_error_status()
+    {
+        $page = $this->entities->page();
+        $command = 'exit 1';
+        config()->set('exports.pdf_command', $command);
+
+        $this->assertThrows(function () use ($page) {
+            $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf'));
+        }, PdfExportException::class);
+    }
+
     public function test_html_exports_contain_csp_meta_tag()
     {
         $entities = [
index aedcb75aa19488ad83a59c82b85420199c0460ae..d5c74392ffcdd42eae0b95520facf521590c61f3 100644 (file)
@@ -80,22 +80,22 @@ class ConfigTest extends TestCase
 
     public function test_dompdf_remote_fetching_controlled_by_allow_untrusted_server_fetching_false()
     {
-        $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'false', 'dompdf.options.enable_remote', false);
-        $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'true', 'dompdf.options.enable_remote', true);
+        $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'false', 'exports.dompdf.enable_remote', false);
+        $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'true', 'exports.dompdf.enable_remote', true);
     }
 
     public function test_dompdf_paper_size_options_are_limited()
     {
-        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'cat', 'dompdf.options.default_paper_size', 'a4');
-        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'letter', 'dompdf.options.default_paper_size', 'letter');
-        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'a4', 'dompdf.options.default_paper_size', 'a4');
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'cat', 'exports.dompdf.default_paper_size', 'a4');
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'letter', 'exports.dompdf.default_paper_size', 'letter');
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'a4', 'exports.dompdf.default_paper_size', 'a4');
     }
 
     public function test_snappy_paper_size_options_are_limited()
     {
-        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'cat', 'snappy.pdf.options.page-size', 'A4');
-        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'letter', 'snappy.pdf.options.page-size', 'Letter');
-        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'a4', 'snappy.pdf.options.page-size', 'A4');
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'cat', 'exports.snappy.options.page-size', 'A4');
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'letter', 'exports.snappy.options.page-size', 'Letter');
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'a4', 'exports.snappy.options.page-size', 'A4');
     }
 
     public function test_sendmail_command_is_configurable()