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
# 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
$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);
// 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');
}
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'.
/**
* 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);
// 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
<?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.
*
/**
* Whether to enable font subsetting or not.
*/
- 'enable_fontsubsetting' => false,
+ 'enable_font_subsetting' => false,
/**
* The PDF rendering backend to use.
*
* @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.
*/
'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.
*
*/
'enable_html5_parser' => true,
],
-
];
'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'),
'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),
],
+++ /dev/null
-<?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' => [],
- ],
-];
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)
+ };
}
/**
*/
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 = [
+ '€' => '€',
+ '£' => '£',
+ ];
+
+ foreach ($entities as $search => $replace) {
+ $subject = str_replace($search, $replace, $subject);
+ }
+ return $subject;
}
}
--- /dev/null
+<?php
+
+namespace BookStack\Exceptions;
+
+class PdfExportException extends \Exception
+{
+}
"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",
"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",
- }
- ],
- "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",
- }
- ],
- "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",
<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>
* [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)_
});
}
+/**
+ * @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
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);
}
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;
await this.show();
this.languageInputChange(language);
this.editor.setContent(code);
+ this.setDirection(direction);
}
async show() {
});
}
+ setDirection(direction) {
+ const target = this.editorInput.parentElement;
+ if (direction) {
+ target.setAttribute('dir', direction);
+ } else {
+ target.removeAttribute('dir');
+ }
+ }
+
hide() {
this.getPopup().hide();
this.addHistory();
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);
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));
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);
// 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);
+ }
}
}
});
* @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);
* @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);
});
}
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);
});
}
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);
}
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',
});
}
+/**
+ * @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
*/
registerPrimaryToolbarGroups(editor);
registerLinkContextToolbar(editor);
registerImageContextToolbar(editor);
+ registerObjectContextToolbar(editor);
}
flex: 0;
.popup-title {
color: #FFF;
- margin-right: auto;
+ margin-inline-end: auto;
padding: 8px $-m;
}
&.flex-container-row {
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
*/
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;
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;
'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);
$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);
$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();
+ }
}
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
{
$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'));
$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 = [
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()