From b6d6d5670231cbf64f04f6fcc96ce0ea664f98bc Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Sun, 9 Aug 2026 08:52:25 +0200 Subject: [PATCH 1/4] Hand resolved media files to mpdf in both path arguments mpdf used the resolved local file only when its basepathIsLocal flag was set. That flag is false whenever the baseurl host differs from the request host, which a non-default port is enough to cause, since HTTP_HOST carries the port while the parsed basepath host does not. Media was then fetched over HTTP again or, for the dw2pdf:// scheme, not at all, leaving exports without images and without an error message. Fixes a regression from 3c796bb. --- _test/DokuAssetFetcherTest.php | 107 +++++++++++++++++++++++++++++++++ src/DokuAssetFetcher.php | 13 +++- 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 _test/DokuAssetFetcherTest.php diff --git a/_test/DokuAssetFetcherTest.php b/_test/DokuAssetFetcherTest.php new file mode 100644 index 0000000..e6b5436 --- /dev/null +++ b/_test/DokuAssetFetcherTest.php @@ -0,0 +1,107 @@ +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); + } +} diff --git a/src/DokuAssetFetcher.php b/src/DokuAssetFetcher.php index 82d2688..3668ae0 100644 --- a/src/DokuAssetFetcher.php +++ b/src/DokuAssetFetcher.php @@ -9,10 +9,21 @@ */ class DokuAssetFetcher extends AssetFetcher { + /** + * 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. + * + * @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']; + if ($resolved) $path = $originalSrc = $resolved['path']; return parent::fetchDataFromPath($path, $originalSrc); } } From 380e19c24eff5ade77499a7f99293dfc5204ec8a Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Tue, 25 Aug 2026 08:36:11 +0200 Subject: [PATCH 2/4] Download external media in the export itself media_get_from_URL() refuses to download anything unless the wiki's fetchsize is set, which it is not by default. A browser is redirected to the original URL in that case, but an export has no client to delegate to, so external images were missing from the PDF without an error. The new fetchsize setting caps what an export may download and disables downloading when set to zero. The cache mode now comes from the media URL through calc_cache(), the way fetch.php reads it, so a page asking for nocache or recache is honoured. The wiki's cachetime was applied to every image before, which also turned a cachetime of 0 into nocache for the whole wiki. Media marked nocache is downloaded on every export rather than refused, again because an export has no browser it could redirect to. Fixes #562, a regression from 3c796bb. --- _test/ConfigTest.php | 3 ++ _test/MediaLinkResolverTest.php | 89 +++++++++++++++++++++++++++++++-- conf/default.php | 1 + conf/metadata.php | 1 + lang/en/settings.php | 1 + src/Config.php | 12 +++++ src/DokuAssetFetcher.php | 27 +++++++++- src/DokuMpdf.php | 10 +++- src/HttpClient.php | 11 ++++ src/MediaLinkResolver.php | 56 ++++++++++++++++++--- 10 files changed, 197 insertions(+), 14 deletions(-) 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/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 3668ae0..903495d 100644 --- a/src/DokuAssetFetcher.php +++ b/src/DokuAssetFetcher.php @@ -3,12 +3,37 @@ namespace dokuwiki\plugin\dw2pdf\src; 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 * @@ -22,7 +47,7 @@ class DokuAssetFetcher extends AssetFetcher */ public function fetchDataFromPath($path, $originalSrc = null) { - $resolved = (new MediaLinkResolver())->resolve($path); + $resolved = $this->resolver->resolve($path); if ($resolved) $path = $originalSrc = $resolved['path']; return parent::fetchDataFromPath($path, $originalSrc); } 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..2fc2936 100644 --- a/src/HttpClient.php +++ b/src/HttpClient.php @@ -19,6 +19,17 @@ class HttpClient implements ClientInterface, LoggerAwareInterface { use PsrLogAwareTrait; + /** @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; + } + /** * Send the HTTP request using Dokuwiki's HTTP client, falling back to media resolution when possible. * diff --git a/src/MediaLinkResolver.php b/src/MediaLinkResolver.php index a51ee08..9cd18ca 100644 --- a/src/MediaLinkResolver.php +++ b/src/MediaLinkResolver.php @@ -9,6 +9,17 @@ */ 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 +36,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); @@ -72,19 +83,24 @@ protected function extractMediaID(string $file): ?string } /** - * Extract media parameters (width, height, revision) from the given file URL. + * Extract media parameters from the given file URL. * - * When a parameter is not found, its value will be 0. + * 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 +114,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 +180,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. * From 952402a141a470f0255349a246ad46ffae9aac97 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Tue, 25 Aug 2026 08:36:47 +0200 Subject: [PATCH 3/4] Restrict the HTTP requests an export may make Media this wiki serves was requested back from fetch.php whenever it could not be resolved. Such a request is anonymous, so it cannot reach media the exporting user may read, and for external media fetch.php redirects to the origin, which downloaded the file outside the export's own limit. It is refused now. Remote assets mpdf still loads over HTTP, such as external stylesheets and fonts, went through an unbounded DokuHTTPClient. The fetchsize setting caps them now, and a limit of zero refuses the request without contacting the host. Both failures are logged. They used to pass silently because mpdf is wired with a NullLogger. --- _test/DokuAssetFetcherTest.php | 16 ++++++++++++++++ _test/HttpClientTest.php | 30 +++++++++++++++++++++++++++++ src/DokuAssetFetcher.php | 22 +++++++++++++++++++-- src/HttpClient.php | 13 ++++++++++++- src/MediaLinkResolver.php | 35 +++++++++++++++++++++++++++------- 5 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 _test/HttpClientTest.php diff --git a/_test/DokuAssetFetcherTest.php b/_test/DokuAssetFetcherTest.php index e6b5436..67ea591 100644 --- a/_test/DokuAssetFetcherTest.php +++ b/_test/DokuAssetFetcherTest.php @@ -104,4 +104,20 @@ public function testFetchDataFromPathReadsDw2pdfScheme(bool $basepathIsLocal): v $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/src/DokuAssetFetcher.php b/src/DokuAssetFetcher.php index 903495d..267230d 100644 --- a/src/DokuAssetFetcher.php +++ b/src/DokuAssetFetcher.php @@ -2,6 +2,7 @@ namespace dokuwiki\plugin\dw2pdf\src; +use dokuwiki\Logger; use Mpdf\AssetFetcher; use Mpdf\File\LocalContentLoaderInterface; use Mpdf\Http\ClientInterface; @@ -41,6 +42,9 @@ public function __construct( * 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 @@ -48,7 +52,21 @@ public function __construct( public function fetchDataFromPath($path, $originalSrc = null) { $resolved = $this->resolver->resolve($path); - if ($resolved) $path = $originalSrc = $resolved['path']; - return parent::fetchDataFromPath($path, $originalSrc); + + 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/HttpClient.php b/src/HttpClient.php index 2fc2936..25dc92c 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; @@ -31,7 +32,10 @@ public function __construct(Config $config) } /** - * Send the HTTP request using Dokuwiki's HTTP client, falling back to media resolution when possible. + * 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 */ @@ -41,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 9cd18ca..c5cf258 100644 --- a/src/MediaLinkResolver.php +++ b/src/MediaLinkResolver.php @@ -57,8 +57,8 @@ 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! * @@ -67,13 +67,9 @@ public function resolve(string $file): ?array */ 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]); @@ -82,6 +78,31 @@ protected function extractMediaID(string $file): ?string return null; } + /** + * Check whether the given URL is one this wiki serves media from. + * + * @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. * From 6aca71cff29d635e9224792c80b3037d03e46f98 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Tue, 25 Aug 2026 08:37:02 +0200 Subject: [PATCH 4/4] Fix stale and inaccurate comments MediaLinkResolver pointed at the removed ImageProcessor and claimed extractMediaID() determines the mime type, which resolve() does. Both class headers carried a hedge. --- src/HttpClient.php | 2 +- src/MediaLinkResolver.php | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/HttpClient.php b/src/HttpClient.php index 25dc92c..dd9f28c 100644 --- a/src/HttpClient.php +++ b/src/HttpClient.php @@ -14,7 +14,7 @@ /** * 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 { diff --git a/src/MediaLinkResolver.php b/src/MediaLinkResolver.php index c5cf258..86adb27 100644 --- a/src/MediaLinkResolver.php +++ b/src/MediaLinkResolver.php @@ -3,9 +3,7 @@ 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 { @@ -60,14 +58,13 @@ public function resolve(string $file): ?array * 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 { - // extract the real media from a fetch.php URI and determine mime if ( preg_match('/^' . $this->mediaUrlRegex() . '/', $file, $matches) || preg_match('/[&?]media=([^&?]*)/', $file, $matches)