diff --git a/_test/ConfigTest.php b/_test/ConfigTest.php index c61f922..79b6e3f 100644 --- a/_test/ConfigTest.php +++ b/_test/ConfigTest.php @@ -50,6 +50,7 @@ public function testDefaults(): void $this->assertNull($config->getSavedSelection(), 'default savedselection'); $this->assertSame('', $config->getExportId(), 'default exportid'); $this->assertTrue($config->useCache(), 'default usecache'); + $this->assertSame(2 * 1024 * 1024, $config->getFetchSize(), 'default fetchsize'); $this->assertSame('A4', $mpdfConfig['format'], 'default pagesize/orientation'); $this->assertSame(11, $mpdfConfig['default_font_size'], 'default font-size'); @@ -86,6 +87,7 @@ public function testloadPluginConfig(): void 'template' => 'modern', 'output' => 'inline', 'usestyles' => 'wrap,foo ', + 'fetchsize' => 1024, 'watermark' => 'CONFIDENTIAL', 'qrcodescale' => '2.5', 'debug' => 1, @@ -111,6 +113,7 @@ public function testloadPluginConfig(): void $this->assertSame('modern', $config->getTemplateName(), 'from template'); $this->assertSame('inline', $config->getOutputTarget(), 'from output'); $this->assertSame(['wrap', 'foo'], $config->getStyledExtensions(), 'from usestyles'); + $this->assertSame(1024, $config->getFetchSize(), 'from fetchsize'); $this->assertSame('CONFIDENTIAL', $config->getWatermarkText(), 'from watermark'); $this->assertTrue($mpdfConfig['showWatermarkText'], 'from watermark'); $this->assertSame(2.5, $config->getQRScale(), 'from qrcodescale'); diff --git a/_test/DokuAssetFetcherTest.php b/_test/DokuAssetFetcherTest.php new file mode 100644 index 0000000..67ea591 --- /dev/null +++ b/_test/DokuAssetFetcherTest.php @@ -0,0 +1,123 @@ +basepathIsLocal = $basepathIsLocal; + + return (new ReflectionProperty(Mpdf::class, 'assetFetcher'))->getValue($mpdf); + } + + /** + * @return array + */ + public static function fetchProvider(): array + { + global $conf; + + $media = DOKU_URL . 'lib/exe/fetch.php?media=wiki:dokuwiki-128.png'; + $mediaFile = $conf['mediadir'] . '/wiki/dokuwiki-128.png'; + $static = DOKU_URL . 'lib/images/throbber.gif'; + $staticFile = DOKU_INC . 'lib/images/throbber.gif'; + + return [ + 'fetch url, local basepath' => [$media, $mediaFile, true], + 'fetch url, remote basepath' => [$media, $mediaFile, false], + 'static file, local basepath' => [$static, $staticFile, true], + 'static file, remote basepath' => [$static, $staticFile, false], + ]; + } + + /** + * The local file has to be read regardless of the basepath setting. + * + * The second argument is the unmodified source as mpdf passes it for every image. + * + * @dataProvider fetchProvider + */ + public function testFetchDataFromPathReadsLocalFile(string $source, string $expected, bool $basepathIsLocal): void + { + $data = $this->getFetcher($basepathIsLocal)->fetchDataFromPath($source, $source); + + $this->assertSame(file_get_contents($expected), $data); + } + + /** + * The resolved file has to win over the source mpdf was originally given. + * + * With a local basepath mpdf prefers the second argument whenever it can open it. For a + * media URL that means an anonymous HTTP request back into the wiki instead of our local + * copy. A readable file stands in for such a loadable source here. + */ + public function testFetchDataFromPathIgnoresOriginalSource(): void + { + global $conf; + $file = $conf['mediadir'] . '/wiki/dokuwiki-128.png'; + $source = DOKU_URL . 'lib/exe/fetch.php?media=wiki:dokuwiki-128.png'; + $loadable = DOKU_INC . 'lib/images/throbber.gif'; + + $data = $this->getFetcher(true)->fetchDataFromPath($source, $loadable); + + $this->assertSame(file_get_contents($file), $data); + } + + /** + * Our pseudo scheme has no stream wrapper, so it can only ever be read locally. + * + * @testWith [true] + * [false] + */ + public function testFetchDataFromPathReadsDw2pdfScheme(bool $basepathIsLocal): void + { + global $conf; + $file = $conf['mediadir'] . '/wiki/dokuwiki-128.png'; + $source = 'dw2pdf://' . $file; + + $data = $this->getFetcher($basepathIsLocal)->fetchDataFromPath($source, $source); + + $this->assertSame(file_get_contents($file), $data); + } + + /** + * Media this wiki serves is never requested back over HTTP when it cannot be resolved. + * + * @testWith [true] + * [false] + */ + public function testFetchDataFromPathRefusesUnresolvableMedia(bool $basepathIsLocal): void + { + $source = DOKU_URL . 'lib/exe/fetch.php?media=wiki:no-such-image.png'; + $this->expectLogMessage('Media not available for PDF export'); + + $data = $this->getFetcher($basepathIsLocal)->fetchDataFromPath($source, $source); + + $this->assertSame('', $data); + } +} diff --git a/_test/HttpClientTest.php b/_test/HttpClientTest.php new file mode 100644 index 0000000..a1e26d0 --- /dev/null +++ b/_test/HttpClientTest.php @@ -0,0 +1,30 @@ +expectLogMessage('Remote asset not downloaded for PDF export'); + + $request = new Request('GET', 'https://example.org/does-not-matter.png'); + $response = (new HttpClient(new Config(['fetchsize' => 0])))->sendRequest($request); + + $this->assertSame(403, $response->getStatusCode()); + } +} diff --git a/_test/MediaLinkResolverTest.php b/_test/MediaLinkResolverTest.php index 7f8ce12..96066b7 100644 --- a/_test/MediaLinkResolverTest.php +++ b/_test/MediaLinkResolverTest.php @@ -2,6 +2,7 @@ namespace dokuwiki\plugin\dw2pdf\test; +use dokuwiki\plugin\dw2pdf\src\Config; use dokuwiki\plugin\dw2pdf\src\MediaLinkResolver; use DokuWikiTest; @@ -18,7 +19,7 @@ class MediaLinkResolverTest extends DokuWikiTest public function setUp(): void { parent::setUp(); - $this->resolver = new MediaLinkResolver(); + $this->resolver = new MediaLinkResolver(new Config()); } /** @@ -80,16 +81,19 @@ public function testResolveDw2pdfScheme(): void } /** + * External media is downloaded even when the wiki does not allow fetch.php to do so. + * * @group internet */ public function testResolveFetchesExternalMedia(): void { global $conf; - $conf['fetchsize'] = 512 * 1024; // 512 KB + $conf['fetchsize'] = 0; + $resolver = new MediaLinkResolver(new Config(['fetchsize' => 512 * 1024])); $external = 'https://php.net/images/php.gif'; $input = DOKU_URL . 'lib/exe/fetch.php?media=' . rawurlencode($external); - $resolved = $this->resolver->resolve($input); + $resolved = $resolver->resolve($input); if ($resolved === null) { $this->markTestSkipped('External media fetching is not available in this environment.'); @@ -101,6 +105,85 @@ public function testResolveFetchesExternalMedia(): void $this->assertSame(2523, filesize($resolved['path'])); } + /** + * Downloading external media can be switched off in the plugin configuration. + */ + public function testResolveSkipsExternalMediaWhenDownloadingIsDisabled(): void + { + $resolver = new MediaLinkResolver(new Config(['fetchsize' => 0])); + + $external = 'https://php.net/images/php.gif'; + $input = DOKU_URL . 'lib/exe/fetch.php?media=' . rawurlencode($external); + + $this->assertNull($resolver->resolve($input)); + } + + /** + * Media marked nocache is still downloaded. + * + * @group internet + */ + public function testResolveFetchesNocacheExternalMedia(): void + { + $resolver = new MediaLinkResolver(new Config(['fetchsize' => 512 * 1024])); + + $external = 'https://php.net/images/php.gif'; + $input = DOKU_URL . 'lib/exe/fetch.php?media=' . rawurlencode($external) . '&cache=nocache'; + $resolved = $resolver->resolve($input); + + if ($resolved === null) { + $this->markTestSkipped('External media fetching is not available in this environment.'); + } + + $this->assertSame(2523, filesize($resolved['path'])); + } + + /** + * Without a cache parameter an existing copy is used no matter how old it is. + */ + public function testResolveKeepsCachedExternalMediaByDefault(): void + { + $external = 'http://127.0.0.1:1/unreachable.png'; + $cacheFile = $this->seedOutdatedCache($external); + + $resolver = new MediaLinkResolver(new Config(['fetchsize' => 1024 * 1024])); + $input = DOKU_URL . 'lib/exe/fetch.php?media=' . rawurlencode($external); + + $resolved = $resolver->resolve($input); + + $this->assertNotNull($resolved); + $this->assertSame($cacheFile, $resolved['path']); + } + + /** + * Media marked recache expires, so an outdated copy is fetched again. + */ + public function testResolveExpiresCachedExternalMediaOnRecache(): void + { + $external = 'http://127.0.0.1:1/unreachable.png'; + $this->seedOutdatedCache($external); + + $resolver = new MediaLinkResolver(new Config(['fetchsize' => 1024 * 1024])); + $input = DOKU_URL . 'lib/exe/fetch.php?media=' . rawurlencode($external) . '&cache=recache'; + + $this->assertNull($resolver->resolve($input)); + } + + /** + * Write a week old cache file for the given external media URL. + * + * @param string $url The external media URL. + * @return string Absolute path to the cache file. + */ + protected function seedOutdatedCache(string $url): string + { + $cacheFile = getCacheName(strtolower($url), '.media.png'); + io_saveFile($cacheFile, 'outdated bytes'); + touch($cacheFile, time() - 7 * 86400); + + return $cacheFile; + } + /** * Non-image payloads should never be returned to the PDF generator. */ diff --git a/conf/default.php b/conf/default.php index df4ff74..5b3cccc 100644 --- a/conf/default.php +++ b/conf/default.php @@ -12,6 +12,7 @@ $conf['template'] = 'default'; $conf['output'] = 'browser'; $conf['usecache'] = 1; +$conf['fetchsize'] = 2 * 1024 * 1024; $conf['usestyles'] = 'wrap,'; $conf['qrcodescale'] = '1'; $conf['showexportbutton'] = 1; diff --git a/conf/metadata.php b/conf/metadata.php index ec08ae1..adc5bbd 100644 --- a/conf/metadata.php +++ b/conf/metadata.php @@ -12,6 +12,7 @@ $meta['template'] = array('dirchoice', '_dir' => DOKU_PLUGIN . 'dw2pdf/tpl/'); $meta['output'] = array('multichoice', '_choices' => array('browser', 'file')); $meta['usecache'] = array('onoff'); +$meta['fetchsize'] = array('numeric'); $meta['usestyles'] = array('string'); $meta['qrcodescale'] = array('string', '_pattern' => '/^(|\d+(\.\d+)?)$/'); $meta['showexportbutton'] = array('onoff'); diff --git a/lang/en/settings.php b/lang/en/settings.php index 56538f6..6703ffc 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -22,6 +22,7 @@ $lang['output_o_browser'] = 'Show in browser'; $lang['output_o_file'] = 'Download the PDF'; $lang['usecache'] = 'Should PDFs be cached? Embedded images won\'t be ACL checked then, disable if that\'s a security concern for you.'; +$lang['fetchsize'] = 'Maximum size (bytes) an export may download per remote file, such as an external image or font. 0 disables downloading, those files are then missing from the PDF.'; $lang['usestyles'] = 'You can give a comma separated list of plugins of which the style.css or screen.css should be used for PDF generation. By default only print.css and pdf.css are used.'; $lang['qrcodescale'] = 'Size scaling of the embedded QR code. Empty or 0 to disable.'; $lang['showexportbutton'] = 'Show PDF export button (only when supported by your template)'; diff --git a/src/Config.php b/src/Config.php index 2552dfe..22e352a 100644 --- a/src/Config.php +++ b/src/Config.php @@ -35,6 +35,8 @@ class Config #[FromConfig] protected bool $usecache = true; #[FromConfig] + protected int $fetchSize = 2 * 1024 * 1024; + #[FromConfig] protected array $useStyles = []; #[FromConfig] protected float $qrCodeScale = 0.0; @@ -196,6 +198,16 @@ public function useCache(): bool return $this->usecache; } + /** + * Get the maximum number of bytes to download per remote file + * + * @return int + */ + public function getFetchSize(): int + { + return $this->fetchSize; + } + /** * Get a list of extensions whose screen styles should be applied * diff --git a/src/DokuAssetFetcher.php b/src/DokuAssetFetcher.php index 82d2688..267230d 100644 --- a/src/DokuAssetFetcher.php +++ b/src/DokuAssetFetcher.php @@ -2,17 +2,71 @@ namespace dokuwiki\plugin\dw2pdf\src; +use dokuwiki\Logger; use Mpdf\AssetFetcher; +use Mpdf\File\LocalContentLoaderInterface; +use Mpdf\Http\ClientInterface; +use Mpdf\Mpdf; +use Psr\Log\LoggerInterface; /** * Wrapper for AssetFetcher which resolves DokuWiki media paths */ class DokuAssetFetcher extends AssetFetcher { + /** @var MediaLinkResolver Translates media references into local files */ + protected MediaLinkResolver $resolver; + + /** + * @param Mpdf $mpdf The document the assets are loaded for + * @param LocalContentLoaderInterface $contentLoader Reads files from disk + * @param ClientInterface $http Client for remote requests + * @param LoggerInterface $logger Where mpdf reports asset problems + * @param Config $config The configuration of the current export + */ + public function __construct( + Mpdf $mpdf, + LocalContentLoaderInterface $contentLoader, + ClientInterface $http, + LoggerInterface $logger, + Config $config + ) { + parent::__construct($mpdf, $contentLoader, $http, $logger); + $this->resolver = new MediaLinkResolver($config); + } + + /** + * Load the given asset, preferring a local copy of Dokuwiki media over an HTTP request + * + * Both arguments are overwritten with the resolved file, because mpdf picks either one + * depending on its basepathIsLocal flag. Leaving a URL in one of them would make mpdf + * fetch the asset over HTTP again. + * + * Media this wiki serves itself is never requested back over HTTP. Such a request is + * anonymous and cannot reach media the exporting user may read. + * + * @param string $path Media reference or URL to load + * @param string|null $originalSrc The unmodified source as given in the HTML + * @return string The asset's binary data, empty when it could not be loaded + */ public function fetchDataFromPath($path, $originalSrc = null) { - $resolved = (new MediaLinkResolver())->resolve($path); - if ($resolved) $originalSrc = $resolved['path']; - return parent::fetchDataFromPath($path, $originalSrc); + $resolved = $this->resolver->resolve($path); + + if (!$resolved) { + if ($this->resolver->isMediaUrl($path)) { + Logger::error('Media not available for PDF export', $path); + return ''; + } + return parent::fetchDataFromPath($path, $originalSrc); + } + + $path = $originalSrc = $resolved['path']; + $data = parent::fetchDataFromPath($path, $originalSrc); + if ($data === '') { + Logger::error('Resolved media could not be read for PDF export', $path); + } + + return $data; } } diff --git a/src/DokuMpdf.php b/src/DokuMpdf.php index 2dac481..56f0d89 100644 --- a/src/DokuMpdf.php +++ b/src/DokuMpdf.php @@ -32,11 +32,17 @@ public function __construct(Config $config, string $lang) $initConfig = $config->getMPdfConfig(); $initConfig['mode'] = $this->lang2mode($lang); - $http = new HttpClient(); + $http = new HttpClient($config); $container = new SimpleContainer([ 'httpClient' => $http, - 'assetFetcher' => new DokuAssetFetcher($this, new MpdfContenLoader(), $http, new NullLogger()) + 'assetFetcher' => new DokuAssetFetcher( + $this, + new MpdfContenLoader(), + $http, + new NullLogger(), + $config + ) ]); parent::__construct($initConfig, $container); diff --git a/src/HttpClient.php b/src/HttpClient.php index fdafebb..dd9f28c 100644 --- a/src/HttpClient.php +++ b/src/HttpClient.php @@ -3,6 +3,7 @@ namespace dokuwiki\plugin\dw2pdf\src; use dokuwiki\HTTP\DokuHTTPClient; +use dokuwiki\Logger; use Mpdf\Http\ClientInterface; use Mpdf\PsrHttpMessageShim\Response; use Mpdf\PsrHttpMessageShim\Stream; @@ -13,14 +14,28 @@ /** * mPDF HTTP client adapter that routes requests through Dokuwiki's HTTP stack. * - * Basically wraps a simple, naive PSR-7 implementation around DokuHTTPClient. + * Wraps DokuHTTPClient in the PSR-7 shim mpdf expects. */ class HttpClient implements ClientInterface, LoggerAwareInterface { use PsrLogAwareTrait; + /** @var Config The configuration of the current export */ + protected Config $config; + /** - * Send the HTTP request using Dokuwiki's HTTP client, falling back to media resolution when possible. + * @param Config $config The configuration of the current export + */ + public function __construct(Config $config) + { + $this->config = $config; + } + + /** + * Send the HTTP request using Dokuwiki's HTTP client. + * + * The export's download limit caps each response. A limit of zero refuses the request + * without contacting the host. * * @inheritDoc */ @@ -30,8 +45,15 @@ public function sendRequest(RequestInterface $request) $url = (string)$uri; + $maxSize = $this->config->getFetchSize(); + if (!$maxSize) { + Logger::error('Remote asset not downloaded for PDF export', $url); + return (new Response())->withStatus(403); + } + // standard Dokuwiki HTTP client for any remote content $client = new DokuHTTPClient(); + $client->max_bodysize = $maxSize; $client->headers = $this->buildHeaders($request); $client->referer = $request->getHeaderLine('Referer'); if ($agent = $request->getHeaderLine('User-Agent')) { diff --git a/src/MediaLinkResolver.php b/src/MediaLinkResolver.php index a51ee08..86adb27 100644 --- a/src/MediaLinkResolver.php +++ b/src/MediaLinkResolver.php @@ -3,12 +3,21 @@ namespace dokuwiki\plugin\dw2pdf\src; /** - * Translates Dokuwiki-specific media URLs into local cached files. - * - * This consolidates the logic previously handled inside the custom ImageProcessor. + * Translates Dokuwiki media URLs into local file paths. */ class MediaLinkResolver { + /** @var Config The configuration of the current export */ + protected Config $config; + + /** + * @param Config $config The configuration of the current export + */ + public function __construct(Config $config) + { + $this->config = $config; + } + /** * Resolve a Dokuwiki media URL or local path to a cached file path. * @@ -25,10 +34,10 @@ public function resolve(string $file): ?array { $mediaID = $this->extractMediaID($file); if ($mediaID !== null) { - [$w, $h, $rev] = $this->extractMediaParams($file); + [$w, $h, $rev, $cache] = $this->extractMediaParams($file); [$ext, $mime] = mimetype($mediaID); if (!$ext) return null; - $localFile = $this->localMediaFile($mediaID, $ext, $rev); + $localFile = $this->localMediaFile($mediaID, $ext, $rev, $cache); if (!$localFile) return null; if (str_starts_with($mime, 'image/')) { $localFile = $this->resizedMedia($localFile, $ext, $w, $h); @@ -46,23 +55,18 @@ public function resolve(string $file): ?array /** * Check if the given file URL corresponds to a Dokuwiki media ID and extract it. * - * Handles rewritten media URLs (/media/*) and fetch.php calls by building a regex - * from the result of calling ml() for a fake media ID. + * Accepts the media URLs this wiki produces as well as any other URL carrying a media + * parameter. * - * Note that the returned media ID could still be an external URL! + * The returned media ID may be an external URL. * * @param string $file * @return string|null The extracted media ID, or null if not found. */ protected function extractMediaID(string $file): ?string { - // build regex to parse URL back to media info (matches fetch.php calls) - $fetchRegex = preg_quote(ml('xxx123yyy', '', true, '&', true), '/'); - $fetchRegex = str_replace('xxx123yyy', '([^&\?]*)', $fetchRegex); - - // extract the real media from a fetch.php URI and determine mime if ( - preg_match("/^$fetchRegex/", $file, $matches) || + preg_match('/^' . $this->mediaUrlRegex() . '/', $file, $matches) || preg_match('/[&?]media=([^&?]*)/', $file, $matches) ) { return rawurldecode($matches[1]); @@ -72,19 +76,49 @@ protected function extractMediaID(string $file): ?string } /** - * Extract media parameters (width, height, revision) from the given file URL. + * Check whether the given URL is one this wiki serves media from. * - * When a parameter is not found, its value will be 0. + * @param string $file Original media reference or URL. + * @return bool + */ + public function isMediaUrl(string $file): bool + { + return (bool)preg_match('/^' . $this->mediaUrlRegex() . '/', $file); + } + + /** + * Build a regex matching the media URLs this wiki produces. + * + * Handles rewritten media URLs and fetch.php calls alike by inspecting what ml() returns + * for a fake media ID. + * + * @return string Regex without delimiters or anchors, capturing the media ID. + */ + protected function mediaUrlRegex(): string + { + $regex = preg_quote(ml('xxx123yyy', '', true, '&', true), '/'); + return str_replace('xxx123yyy', '([^&\?]*)', $regex); + } + + /** + * Extract media parameters from the given file URL. + * + * When a size or revision parameter is not found, its value will be 0. The cache mode + * defaults to caching endlessly. * * @param string $file Source string (fetch call) - * @return array{int,int,int} Array containing width, height, and revision. + * @return array{int,int,int,int} Array containing width, height, revision and cache mode. */ protected function extractMediaParams(string $file): array { + // calc_cache() lives in a file Dokuwiki only loads for fetch.php + require_once(DOKU_INC . 'inc/fetch.functions.php'); + $width = $this->extractInt($file, 'w'); $height = $this->extractInt($file, 'h'); $rev = $this->extractInt($file, 'rev'); - return [$width, $height, $rev]; + $cache = calc_cache($this->extractStr($file, 'cache')); + return [$width, $height, $rev, $cache]; } /** @@ -98,14 +132,21 @@ protected function extractMediaParams(string $file): array * @param string $mediaID A media ID or external URL. * @param string $ext File extension (used for external media caching). * @param int $rev Revision number (0 for latest). + * @param int $cache Cache mode as returned by calc_cache(). * @return string|null Absolute path to the local media file, or null when not accessible. */ - protected function localMediaFile(string $mediaID, string $ext, int $rev): ?string + protected function localMediaFile(string $mediaID, string $ext, int $rev, int $cache): ?string { global $conf; if (media_isexternal($mediaID)) { - $local = media_get_from_URL($mediaID, $ext, $conf['cachetime']); + // an export has no browser to redirect to, so nocache media is fetched every time + if ($cache === 0) $cache = 1; + // the wiki's fetchsize governs fetch.php, so an export applies its own limit + $globalFetchsize = $conf['fetchsize']; + $conf['fetchsize'] = $this->config->getFetchSize(); + $local = media_get_from_URL($mediaID, $ext, $cache); + $conf['fetchsize'] = $globalFetchsize; if (!$local) return null; } else { $mediaID = cleanID($mediaID); @@ -157,6 +198,23 @@ protected function extractInt(string $subject, string $param): int return 0; } + /** + * Extract a string parameter from the given subject URL. + * + * @param string $subject Source string, usually the media URL. + * @param string $param Name of the parameter to extract. + * @return string Empty when the parameter is not present. + */ + protected function extractStr(string $subject, string $param): string + { + $pattern = '/[?&]' . $param . '=([^&]*)/'; + if (preg_match($pattern, $subject, $match)) { + return rawurldecode($match[1]); + } + + return ''; + } + /** * Attempt to extract a local file path from the given URL. *