From b8ac3bcecdd9d366ef620e80d87da6f83fcb7462 Mon Sep 17 00:00:00 2001 From: JamBalaya56562 Date: Sun, 12 Jul 2026 16:51:03 +0900 Subject: [PATCH] [Image Proxy] Replace deprecated HTML entity conversion --- .github/workflows/tests.yml | 3 + composer.json | 2 + tests/ImageProxyTest.php | 248 ++++++++++++++++++++++++++++ xExtension-ImageProxy/README.md | 1 + xExtension-ImageProxy/extension.php | 5 +- xExtension-ImageProxy/metadata.json | 2 +- 6 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 tests/ImageProxyTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4de8a825..3b257724 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,6 +49,9 @@ jobs: - name: PHP_CodeSniffer run: composer run-script phpcs + - name: ImageProxy regression tests + run: composer run-script test-image-proxy + - name: Git checkout FreshRSS source code uses: actions/checkout@v7 with: diff --git a/composer.json b/composer.json index b105f969..e1746f8a 100644 --- a/composer.json +++ b/composer.json @@ -69,9 +69,11 @@ "phpcbf": "phpcbf . -p -s", "phpstan": "phpstan analyse .", "phpstan-third-party": "phpstan analyse -c phpstan-third-party.neon .", + "test-image-proxy": "php tests/ImageProxyTest.php", "test": [ "@php-lint", "@phtml-lint", + "@test-image-proxy", "@phpcs", "@phpstan" ], diff --git a/tests/ImageProxyTest.php b/tests/ImageProxyTest.php new file mode 100644 index 00000000..78d099e5 --- /dev/null +++ b/tests/ImageProxyTest.php @@ -0,0 +1,248 @@ + $values */ + public function __construct(private array $values) { + } + + public function attributeBool(string $key): ?bool { + $value = $this->values[$key] ?? null; + return is_bool($value) ? $value : null; + } + + public function attributeString(string $key): ?string { + $value = $this->values[$key] ?? null; + return is_string($value) ? $value : null; + } +} + +final class ImageProxyTestContext { + private static ImageProxyTestConfiguration $configuration; + + /** @param array $values */ + public static function configure(array $values): void { + self::$configuration = new ImageProxyTestConfiguration($values); + } + + public static function userConf(): ImageProxyTestConfiguration { + return self::$configuration; + } +} + +class_alias(ImageProxyTestMinzExtension::class, 'Minz_Extension'); +class_alias(ImageProxyTestContextException::class, 'FreshRSS_Context_Exception'); +class_alias(ImageProxyTestContext::class, 'FreshRSS_Context'); + +require_once __DIR__ . '/../xExtension-ImageProxy/extension.php'; + +final class ImageProxyTest { + private const PROXY_URL = 'https://proxy.example/?url='; + + private int $assertions = 0; + + public function run(): void { + set_error_handler(static function (int $severity, string $message, string $file, int $line): never { + throw new ErrorException($message, 0, $severity, $file, $line); + }); + + try { + $this->configure(); + $this->testEmptyContent(); + $this->testUnicodeText(); + $this->testExistingEntities(); + $this->testInvalidUtf8(); + $this->testHtmlDocumentsAndTagSoup(); + $this->testImageAttributes(); + $this->testUriSchemes(); + } catch (FreshRSS_Context_Exception $e) { + throw new RuntimeException('ImageProxy configuration failed', 0, $e); + } finally { + restore_error_handler(); + } + + fwrite(STDOUT, "ImageProxy regression tests passed ({$this->assertions} assertions).\n"); + } + + private function configure(bool $includeScheme = true, bool $encodeUrl = false): void { + ImageProxyTestContext::configure([ + 'image_proxy_url' => self::PROXY_URL, + 'image_proxy_scheme_http' => true, + 'image_proxy_scheme_https' => true, + 'image_proxy_scheme_default' => 'auto', + 'image_proxy_scheme_include' => $includeScheme, + 'image_proxy_url_encode' => $encodeUrl, + ]); + } + + /** @throws FreshRSS_Context_Exception */ + private function testEmptyContent(): void { + $this->assertSame('', ImageProxyExtension::swapUris(''), 'Empty content must remain empty'); + } + + /** @throws FreshRSS_Context_Exception */ + private function testUnicodeText(): void { + $expected = 'ASCII café 日本語 中文 😀 𝄞'; + $output = ImageProxyExtension::swapUris('

' . $expected . '

'); + $this->assertSame($expected, $this->textContent($output), 'Unicode text must survive the DOM round trip'); + } + + /** @throws FreshRSS_Context_Exception */ + private function testExistingEntities(): void { + $output = ImageProxyExtension::swapUris('

Rock & Roll "quote" © 😀

'); + $this->assertSame('Rock & Roll "quote" © 😀', $this->textContent($output), 'Existing entities must not be double encoded'); + $this->assertNotContains('&', $output, 'Named entities must not be double encoded'); + } + + /** @throws FreshRSS_Context_Exception */ + private function testInvalidUtf8(): void { + $oldSubstitute = mb_substitute_character(); + mb_substitute_character(0xFFFD); + try { + $output = ImageProxyExtension::swapUris("

invalid \xC3\x28 sequence

"); + $this->assertNotSame('', $output, 'Invalid UTF-8 must not discard the complete article'); + $this->assertContains('sequence', $this->textContent($output), 'Text following invalid UTF-8 must be preserved'); + } finally { + mb_substitute_character($oldSubstitute); + } + } + + /** @throws FreshRSS_Context_Exception */ + private function testHtmlDocumentsAndTagSoup(): void { + $document = 'Ignored

Body text

'; + $this->assertSame('Body text', $this->textContent(ImageProxyExtension::swapUris($document)), 'Only body contents must be returned'); + + $tagSoup = ImageProxyExtension::swapUris('

one

two

'); + $this->assertSame('onetwo', $this->textContent($tagSoup), 'Tag soup text must remain readable'); + $this->assertContains('data-kind="sample"', $tagSoup, 'Element attributes must survive parsing'); + } + + /** @throws FreshRSS_Context_Exception */ + private function testImageAttributes(): void { + $input = '

Images

' + . 'café' + . '' + . 'no source'; + $output = ImageProxyExtension::swapUris($input); + $images = $this->images($output); + + $this->assertSame(3, count($images), 'All image elements must remain present'); + $this->assertSame( + self::PROXY_URL . 'http://images.example/one%20caf%C3%A9.jpg', + $images[0]->getAttribute('src'), + 'Unicode image URLs must be proxied', + ); + $this->assertSame( + 'http://images.example/one café.jpg', + $images[0]->getAttribute('data-xextension-imageproxy-original-src'), + 'Original src must be retained', + ); + $this->assertSame('café', $images[0]->getAttribute('alt'), 'Unicode attributes must remain readable'); + $this->assertSame( + self::PROXY_URL . 'http://images.example/small.png 1x, ' . self::PROXY_URL . 'https://images.example/large.png 2x', + $images[1]->getAttribute('srcset'), + 'Every srcset candidate must be proxied', + ); + $this->assertSame( + 'http://images.example/small.png 1x, https://images.example/large.png 2x', + $images[1]->getAttribute('data-xextension-imageproxy-original-srcset'), + 'Original srcset must be retained', + ); + $this->assertSame(false, $images[2]->hasAttribute('src'), 'Images without src must remain without src'); + } + + /** @throws FreshRSS_Context_Exception */ + private function testUriSchemes(): void { + $this->assertSame( + 'data:image/png;base64,abc', + ImageProxyExtension::getProxyImageUri('data:image/png;base64,abc'), + 'Unsupported schemes must not be proxied', + ); + + $_SERVER['HTTPS'] = 'on'; + $this->assertSame( + self::PROXY_URL . 'https://images.example/a.png', + ImageProxyExtension::getProxyImageUri('//images.example/a.png'), + 'Protocol-relative URLs must use the current HTTPS scheme', + ); + + $this->configure(includeScheme: false, encodeUrl: true); + $this->assertSame( + self::PROXY_URL . rawurlencode('images.example/a b.png'), + ImageProxyExtension::getProxyImageUri('http://images.example/a b.png'), + 'Configured scheme removal and URL encoding must be preserved', + ); + } + + private function textContent(string $html): string { + $doc = $this->parseFragment($html); + $body = $doc->getElementsByTagName('body')->item(0); + if (!($body instanceof DOMElement)) { + throw new RuntimeException('Could not parse HTML body'); + } + return $body->textContent; + } + + /** @return list */ + private function images(string $html): array { + $doc = $this->parseFragment($html); + $images = []; + foreach ($doc->getElementsByTagName('img') as $image) { + if ($image instanceof DOMElement) { + $images[] = $image; + } + } + return $images; + } + + private function parseFragment(string $html): DOMDocument { + $doc = new DOMDocument(); + libxml_use_internal_errors(true); + if (!$doc->loadHTML('' . $html . '')) { + throw new RuntimeException('Could not parse HTML fragment'); + } + return $doc; + } + + private function assertSame(mixed $expected, mixed $actual, string $message): void { + ++$this->assertions; + if ($expected !== $actual) { + throw new RuntimeException($message . sprintf("\nExpected: %s\nActual: %s", var_export($expected, true), var_export($actual, true))); + } + } + + private function assertNotSame(mixed $unexpected, mixed $actual, string $message): void { + ++$this->assertions; + if ($unexpected === $actual) { + throw new RuntimeException($message . sprintf("\nUnexpected: %s", var_export($unexpected, true))); + } + } + + private function assertContains(string $needle, string $haystack, string $message): void { + ++$this->assertions; + if (!str_contains($haystack, $needle)) { + throw new RuntimeException($message . sprintf("\nMissing: %s\nActual: %s", var_export($needle, true), var_export($haystack, true))); + } + } + + private function assertNotContains(string $needle, string $haystack, string $message): void { + ++$this->assertions; + if (str_contains($haystack, $needle)) { + throw new RuntimeException($message . sprintf("\nUnexpected: %s\nActual: %s", var_export($needle, true), var_export($haystack, true))); + } + } +} + +(new ImageProxyTest())->run(); diff --git a/xExtension-ImageProxy/README.md b/xExtension-ImageProxy/README.md index ea385214..78ff7888 100644 --- a/xExtension-ImageProxy/README.md +++ b/xExtension-ImageProxy/README.md @@ -6,6 +6,7 @@ To use it, upload this entire directory to the FreshRSS `./extensions` directory ## Changelog +* 1.0.1 Replace deprecated HTML entity conversion on PHP 8.2+ * 1.0 Breaking changes due to significant code upgrade: settings must be saved again * 0.7.3 Turkish language support added diff --git a/xExtension-ImageProxy/extension.php b/xExtension-ImageProxy/extension.php index 770d2c31..ff6a5a13 100644 --- a/xExtension-ImageProxy/extension.php +++ b/xExtension-ImageProxy/extension.php @@ -129,10 +129,7 @@ public static function swapUris(string $content): string { $doc = new DOMDocument(); libxml_use_internal_errors(true); // prevent tag soup errors from showing - $content = mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8'); - if (!is_string($content)) { - return ''; - } + $content = mb_encode_numericentity($content, [0x80, 0x10FFFF, 0, ~0], 'UTF-8'); $doc->loadHTML($content); $imgs = $doc->getElementsByTagName('img'); foreach ($imgs as $img) { diff --git a/xExtension-ImageProxy/metadata.json b/xExtension-ImageProxy/metadata.json index 50db226d..d0212fdd 100644 --- a/xExtension-ImageProxy/metadata.json +++ b/xExtension-ImageProxy/metadata.json @@ -2,7 +2,7 @@ "name": "Image Proxy", "author": "Frans de Jonge", "description": "No insecure content warnings or disappearing images.", - "version": "1.0", + "version": "1.0.1", "entrypoint": "ImageProxy", "type": "user" }