diff --git a/src/class-tiny-apache-rewrite.php b/src/class-tiny-apache-rewrite.php new file mode 100644 index 00000000..cf64a873 --- /dev/null +++ b/src/class-tiny-apache-rewrite.php @@ -0,0 +1,166 @@ +'; + $rules[] = 'RewriteEngine On'; + $rules[] = 'RewriteOptions Inherit'; + + $rules = array_merge( $rules, self::get_avif_rules() ); + $rules = array_merge( $rules, self::get_webp_rules() ); + + $rules[] = ''; + + $rules[] = ''; + $rules[] = ''; + $rules[] = 'Header append Vary Accept'; + $rules[] = ''; + $rules[] = ''; + + $rules[] = ''; + $rules[] = 'AddType image/webp .webp'; + $rules[] = 'AddType image/avif .avif'; + $rules[] = ''; + + return implode( "\n", $rules ); + } + + /** + * Generate AVIF rewrite rules. + * + * @return array[] AVIF rewrite rules + */ + private static function get_avif_rules() { + $rules = array(); + $rules[] = 'RewriteCond %{HTTP_ACCEPT} image/avif'; + $rules[] = 'RewriteCond %{REQUEST_URI} ^(.+)\.(?:jpe?g|png|gif)$'; + $rules[] = 'RewriteCond %{DOCUMENT_ROOT}/%1.avif -f'; + $rules[] = 'RewriteCond %{QUERY_STRING} !type=original'; + $rules[] = 'RewriteRule (.+)\.(?:jpe?g|png|gif)$ $1.avif [T=image/avif,L]'; + return $rules; + } + + /** + * Generate WebP rewrite rules. + * + * @return array[] WebP rewrite rules + */ + private static function get_webp_rules() { + $rules = array(); + $rules[] = 'RewriteCond %{HTTP_ACCEPT} image/webp'; + $rules[] = 'RewriteCond %{REQUEST_URI} ^(.+)\.(?:jpe?g|png|gif)$'; + $rules[] = 'RewriteCond %{DOCUMENT_ROOT}/%1.webp -f'; + $rules[] = 'RewriteCond %{QUERY_STRING} !type=original'; + $rules[] = 'RewriteRule (.+)\.(?:jpe?g|png|gif)$ $1.webp [T=image/webp,L]'; + + return $rules; + } + + /** + * Install rewrite rules to .htaccess files. + * + * @return bool True on success, false otherwise + */ + private static function install_rules() { + $rules = self::get_rewrite_rules(); + + $upload_dir = wp_upload_dir(); + if ( isset( $upload_dir['basedir'] ) && is_writable( $upload_dir['basedir'] ) ) { + $htaccess_file = $upload_dir['basedir'] . '/.htaccess'; + insert_with_markers( $htaccess_file, self::MARKER, $rules ); + } + + return true; + } + + /** + * Remove rewrite rules from .htaccess files. + * + * @return bool True on success, false otherwise + */ + public static function uninstall_rules() { + $upload_dir = wp_upload_dir(); + if ( + file_exists( $upload_dir['basedir'] . '/.htaccess' ) + ) { + $htaccess_file = $upload_dir['basedir'] . '/.htaccess'; + insert_with_markers( $htaccess_file, self::MARKER, '' ); + } + + return true; + } +} diff --git a/src/class-tiny-conversion.php b/src/class-tiny-conversion.php new file mode 100644 index 00000000..6cd319b6 --- /dev/null +++ b/src/class-tiny-conversion.php @@ -0,0 +1,87 @@ +settings = $settings; + } + + /** + * will check if conversion is enabled, + * if true: + * - will enable the delivery method + * - will add hook to toggle rules + * + * hooked into `init` + */ + public function init() { + if ( ! $this->settings->get_conversion_enabled() ) { + return; + } + + add_action( + 'update_option_tinypng_convert_format', + 'Tiny_Apache_Rewrite::toggle_rules', + 20, + 3 + ); + + $delivery_method = $this->settings->get_conversion_delivery_method(); + + $this->init_image_delivery( $delivery_method ); + } + + /** + * Initializes the method of delivery for optimised images + * + * @param string $delivery_method 'picture' or 'htaccess' + * @return void + */ + private function init_image_delivery( $delivery_method ) { + /** + * Controls wether the page should replace with elements + * converted sources. + * + * @since 3.7.0 + */ + if ( 'htaccess' === $delivery_method && Tiny_Server_Capabilities::is_apache() ) { + new Tiny_Apache_Rewrite(); + return; + } + + if ( apply_filters( 'tiny_replace_with_picture', 'picture' === $delivery_method ) ) { + new Tiny_Picture( $this->settings, ABSPATH, array( get_site_url() ) ); + return; + } + } +} diff --git a/src/class-tiny-plugin.php b/src/class-tiny-plugin.php index 1fc976c8..2236332d 100644 --- a/src/class-tiny-plugin.php +++ b/src/class-tiny-plugin.php @@ -40,6 +40,7 @@ public static function version() { public function __construct() { parent::__construct(); $this->settings = new Tiny_Settings(); + new Tiny_Conversion( $this->settings ); } public function set_compressor( $compressor ) { @@ -72,7 +73,6 @@ public function init() { dirname( plugin_basename( __FILE__ ) ) . '/languages' ); - new Tiny_Picture( $this->settings, ABSPATH, array( get_site_url() ) ); $this->tiny_compatibility(); } diff --git a/src/class-tiny-server-capabilities.php b/src/class-tiny-server-capabilities.php new file mode 100644 index 00000000..3655679f --- /dev/null +++ b/src/class-tiny-server-capabilities.php @@ -0,0 +1,116 @@ + self::get_server_type(), + 'is_apache' => self::is_apache(), + 'has_mod_rewrite' => self::has_mod_rewrite(), + 'uploads_writable' => self::uploads_htaccess_writable(), + 'htaccess_available' => self::is_apache() && + self::has_mod_rewrite() && + self::uploads_htaccess_writable(), + ); + } +} diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index e4607b6e..4457d0bb 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -420,6 +420,24 @@ private function get_convertto_mimetype() { return array( 'image/avif', 'image/webp' ); } + /** + * Retrieve the image delivery method. + * + * @return string The delivery method: 'picture' or 'htaccess'. Default is 'picture'. + */ + public function get_conversion_delivery_method() { + return self::get_convert_format_option( 'delivery_method', 'picture' ); + } + + /** + * Check if Apache with mod_rewrite is available. + * + * @return bool True if Apache with mod_rewrite is available, false otherwise + */ + public static function is_apache_available() { + return Tiny_Server_Capabilities::is_apache() && Tiny_Server_Capabilities::has_mod_rewrite(); + } + private function setup_incomplete_checks() { if ( ! $this->get_api_key() ) { $this->notices->api_key_missing_notice(); @@ -1013,95 +1031,40 @@ public function compress_wr2x_images() { public function render_format_conversion() { - echo '
'; - - $convertopts_convert = self::get_prefixed_name( 'convert_format[convert]' ); - $convertopts_convert_id = self::get_prefixed_name( 'conversion_convert' ); - $convertopts_convert_checked = $this->get_conversion_enabled() ? - ' checked="checked"' : ''; - - echo '

'; - echo ''; - echo ''; - echo '

'; - - $convertopts_convert_to_name = - self::get_prefixed_name( 'convert_format[convert_to]' ); - $convertopts_convert_subfields_classname = - self::get_prefixed_name( 'convert_fields' ); - $convertopts_convert_to_id = self::get_prefixed_name( 'convert_convert_to' ); - $convertopts_convert_value = - self::get_convert_format_option( 'convert_to', 'smallest' ); - $convertopts_convert_disabled = - self::get_conversion_enabled() ? '' : ' disabled="disabled"'; - echo sprintf( - '
', - $convertopts_convert_subfields_classname, - $convertopts_convert_to_id, - $convertopts_convert_disabled - ); - echo '

' . __( 'Conversion output', 'tiny-compress-images' ) . '

'; - self::render_convert_to_radiobutton( - $convertopts_convert_to_name, - sprintf( self::get_prefixed_name( 'convert_convert_to_%s' ), 'smallest' ), - 'smallest', - $convertopts_convert_value, - __( 'Convert to smallest file type (Recommended)', 'tiny-compress-images' ), - __( - 'We will calculate what is the best format for your image.', - 'tiny-compress-images' - ) - ); - self::render_convert_to_radiobutton( - $convertopts_convert_to_name, - sprintf( self::get_prefixed_name( 'convert_convert_to_%s' ), 'webp' ), - 'webp', - $convertopts_convert_value, - __( 'Convert to WebP', 'tiny-compress-images' ), - __( - 'WebP balances a small file size with good visual quality, - supporting transparency and animation.', - 'tiny-compress-images' - ) - ); - self::render_convert_to_radiobutton( - $convertopts_convert_to_name, - sprintf( self::get_prefixed_name( 'convert_convert_to_%s' ), 'avif' ), - 'avif', - $convertopts_convert_value, - __( 'Convert to AVIF', 'tiny-compress-images' ), - __( - 'AVIF delivers even better compression and image quality than WebP. - Browser support is not as good as WebP.', - 'tiny-compress-images' - ) - ); - echo '
'; - echo '
'; + include __DIR__ . '/views/settings-conversion.php'; } - private static function render_convert_to_radiobutton( - $name, - $id, - $value, - $current, + private static function render_radiobutton( + $group_name, + $option_id, + $option_value, + $current_value, $label, $descr ) { - $checked = ( $current === $value ? ' checked="checked"' : '' ); + $checked = ( $current_value === $option_value ? ' checked="checked"' : '' ); echo '

'; - echo ''; - echo '

'; } + /** + * Render the delivery method settings view. + * Only displays if Apache with mod_rewrite is available. + */ + public function render_delivery_method() { + if ( ! self::is_apache_available() ) { + return; + } + + include __DIR__ . '/views/delivery-method.php'; + } + private static function get_convert_format_option( $option, $default_value ) { $setting = get_option( self::get_prefixed_name( 'convert_format' ) ); if ( isset( $setting[ $option ] ) && $setting[ $option ] ) { diff --git a/src/views/settings-conversion-delivery.php b/src/views/settings-conversion-delivery.php new file mode 100644 index 00000000..e120c5e7 --- /dev/null +++ b/src/views/settings-conversion-delivery.php @@ -0,0 +1,39 @@ + + +

+ + tags with multiple elements.' +); +?> + diff --git a/src/views/settings-conversion-enabled.php b/src/views/settings-conversion-enabled.php new file mode 100644 index 00000000..1c37f8a4 --- /dev/null +++ b/src/views/settings-conversion-enabled.php @@ -0,0 +1,30 @@ +get_conversion_enabled() ? + ' checked="checked"' : ''; +?> + +
+

+ /> + +

+
diff --git a/src/views/settings-conversion-output.php b/src/views/settings-conversion-output.php new file mode 100644 index 00000000..95bbf125 --- /dev/null +++ b/src/views/settings-conversion-output.php @@ -0,0 +1,53 @@ + +

+ + + + + + diff --git a/src/views/settings-conversion.php b/src/views/settings-conversion.php new file mode 100644 index 00000000..19677b18 --- /dev/null +++ b/src/views/settings-conversion.php @@ -0,0 +1,29 @@ + + +
+ + +
> + +
+
diff --git a/src/views/settings.php b/src/views/settings.php index 7e79d1a7..1f70b2a6 100644 --- a/src/views/settings.php +++ b/src/views/settings.php @@ -36,23 +36,23 @@ - - - -

-

- -

- render_format_conversion(); ?> - - - - + + + +

+

+ +

+ render_format_conversion(); ?> + + + + render_resize(); ?> diff --git a/test/helpers/wordpress.php b/test/helpers/wordpress.php index 47ee9487..3123e40f 100644 --- a/test/helpers/wordpress.php +++ b/test/helpers/wordpress.php @@ -103,6 +103,7 @@ public function __construct($vfs) $this->addMethod('wp_send_json_error'); $this->addMethod('get_temp_dir'); $this->addMethod('esc_attr'); + $this->addMethod('insert_with_markers'); $this->defaults(); $this->create_filesystem(); } @@ -200,6 +201,8 @@ public function call($method, $args) return array('basedir' => $this->vfs->url() . '/' . self::UPLOAD_DIR, 'baseurl' => '/' . self::UPLOAD_DIR); } elseif ('is_admin' === $method) { return true; + } elseif ('insert_with_markers' === $method) { + return call_user_func_array(array($mocks, $method), $args); } elseif (method_exists($mocks, $method)) { return call_user_func_array(array($mocks, $method), $args); } @@ -511,6 +514,28 @@ public function esc_attr($text) { return $text; } + + /** + * Mocked function for https://developer.wordpress.org/reference/functions/insert_with_markers/ + * + * @return void + */ + public function insert_with_markers($filename, $marker, $insertion) + { + $content = file_exists($filename) ? file_get_contents($filename) : ''; + $insertion = is_array($insertion) ? implode("\n", $insertion) : $insertion; + + $start = "# BEGIN {$marker}"; + $end = "# END {$marker}"; + + $content = preg_replace('/' . preg_quote($start, '/') . '.*?' . preg_quote($end, '/') . '\s*/s', '', $content); + + if ($insertion) { + $content = "{$start}\n{$insertion}\n{$end}\n" . ltrim($content); + } + + return file_put_contents($filename, trim($content) . "\n") !== false; + } } class WP_HTTP_Proxy diff --git a/test/integration/conversion.spec.ts b/test/integration/conversion.spec.ts index 2b04e547..13670f48 100644 --- a/test/integration/conversion.spec.ts +++ b/test/integration/conversion.spec.ts @@ -30,6 +30,7 @@ test.describe('conversion', () => { await setConversionSettings(page, { convert: true, output: 'smallest', + delivery: 'picture', }); await uploadMedia(page, 'input-example.jpg'); @@ -48,6 +49,11 @@ test.describe('conversion', () => { }); test('will display the optimized image on a page', async () => { + await setConversionSettings(page, { + convert: true, + output: 'smallest', + delivery: 'picture', + }); const media = await uploadMedia(page, 'input-example.jpg'); const postID = await newPost( page, @@ -63,4 +69,37 @@ test.describe('conversion', () => { const picture = await page.locator('picture:has(source[srcset*="input-example.avif"][type="image/avif"])'); await expect(picture).toBeVisible(); }); + + test('will serve optimized image when server side rules are configured', async () => { + await setConversionSettings(page, { + convert: true, + output: 'smallest', + delivery: 'htaccess', + }); + const media = await uploadMedia(page, 'input-example.jpg'); + const postID = await newPost( + page, + { + title: 'test', + content: `
`, + }, + WPVersion + ); + + const imageResponsePromise = page.waitForResponse((response) => response.url().includes('input-example.jpg'), { timeout: 10000 }); + + await page.goto(`/?p=${postID}`); + + await imageResponsePromise; + + const response = await page.request.get(media, { + headers: { + Accept: 'image/avif,image/webp,*/*', // browser automatically add this + }, + }); + const buffer = await response.body(); + const signature = buffer.toString('ascii', 0, 16); + + expect(signature).toContain('ftypavif'); + }); }); diff --git a/test/integration/utils.ts b/test/integration/utils.ts index 1208da7a..01640e24 100644 --- a/test/integration/utils.ts +++ b/test/integration/utils.ts @@ -214,7 +214,7 @@ export async function deactivatePlugin(page: Page, pluginSlug: string) { await plugin.getByLabel('Deactivate').click(); } -export async function setConversionSettings(page: Page, settings: { convert: boolean; output?: 'smallest' | 'webp' | 'avif' }) { +export async function setConversionSettings(page: Page, settings: { convert: boolean; output?: 'smallest' | 'webp' | 'avif'; delivery: 'picture' | 'htaccess' }) { await page.goto('/wp-admin/options-general.php?page=tinify'); if (settings.convert) { @@ -231,6 +231,12 @@ export async function setConversionSettings(page: Page, settings: { convert: boo default: await page.locator('#tinypng_convert_convert_to_smallest').check(); } + + if (settings.delivery === 'htaccess') { + await page.getByTestId('tinypng_convert_delivery_htaccess').check(); + } else { + await page.getByTestId('tinypng_convert_delivery_picture').check(); + } } else { await page.locator('#tinypng_conversion_convert').uncheck(); } diff --git a/test/unit/TinyPluginTest.php b/test/unit/TinyPluginTest.php index 01a73858..f99cd7e2 100644 --- a/test/unit/TinyPluginTest.php +++ b/test/unit/TinyPluginTest.php @@ -468,9 +468,7 @@ public function test_conversion_enabled_but_filtered_off_not_load_picture() } public function test_conversion_enabled_and_not_filtered() - { - $_GET = array(); - + { // Mock settings with compression count $mock_settings = $this->createMock(Tiny_Settings::class); $mock_settings->method('get_conversion_enabled')->willReturn(true); @@ -488,20 +486,13 @@ public function test_conversion_enabled_and_not_filtered() $settings_prop->setAccessible(true); $settings_prop->setValue($tiny_plugin, $mock_settings); - // Init plugin $tiny_plugin->init(); - $template_redirect_registered = false; - foreach ($this->wp->getCalls('add_action') as $call) { - if ($call[0] === 'template_redirect') { - $template_redirect_registered = true; - break; - } - } + $tiny_picture = new Tiny_Picture( $mock_settings ); - $this->assertTrue( - $template_redirect_registered, - 'Expected Tiny_Picture to hook into template_redirect.' - ); + // hook is registered on init + $this->wp->init(); + + WordPressStubs::assertHook('template_redirect', array($tiny_picture, 'on_template_redirect')); } } diff --git a/test/unit/Tiny_Apache_Rewrite_Test.php b/test/unit/Tiny_Apache_Rewrite_Test.php new file mode 100644 index 00000000..7ce052d1 --- /dev/null +++ b/test/unit/Tiny_Apache_Rewrite_Test.php @@ -0,0 +1,77 @@ +shouldReceive('is_apache')->andReturn(true); + + $mock_settings = $this->createMock(Tiny_Settings::class); + $mock_settings->method('get_conversion_enabled')->willReturn(true); + $mock_settings->method('get_conversion_delivery_method')->willReturn('htaccess'); + + new Tiny_Conversion($mock_settings); + + $this->wp->init(); + + WordPressStubs::assertHook('update_option_tinypng_convert_format', 'Tiny_Apache_Rewrite::toggle_rules'); + } + + /** + * uninstall_rules removes htaccess rules from upload directory. + * - creates a htaccess file in uploads directory + * - run uninstall_rules + * - validate if rules are removed + */ + function test_uninstall_rules_removes_upload_dir_htaccess() + { + $upload_dir = $this->vfs->url() . '/wp-content/uploads'; + $htaccess_file = $upload_dir . '/.htaccess'; + + if (!is_dir($upload_dir)) { + mkdir($upload_dir, 0755, true); + } + + $htaccess_content = "# BEGIN tiny-compress-images\nRewriteEngine On\n# END tiny-compress-images"; + file_put_contents($htaccess_file, $htaccess_content); + + $this->assertTrue(file_exists($htaccess_file), 'htaccess should exist before uninstall'); + + Tiny_Apache_Rewrite::uninstall_rules(); + + $contents = file_get_contents($htaccess_file); + $this->assertStringNotContainsString('tiny-compress-images', $contents, 'htaccess should not contain tinify anymore'); + } + + /** + * Test that uninstall_rules handles non-existent upload directory htaccess gracefully. + */ + function test_uninstall_rules_handles_missing_upload_htaccess() + { + $upload_dir = $this->vfs->url() . '/wp-content/uploads'; + $htaccess_file = $upload_dir . '/.htaccess'; + + if (!is_dir($upload_dir)) { + mkdir($upload_dir, 0755, true); + } + + // Ensure file doesn't exist + if (file_exists($htaccess_file)) { + unlink($htaccess_file); + } + + $this->assertFalse(file_exists($htaccess_file), 'htaccess should not exist'); + + // Should not throw error + $result = Tiny_Apache_Rewrite::uninstall_rules(); + + $this->assertTrue($result, 'uninstall_rules should return true even when file does not exist'); + } +} diff --git a/tiny-compress-images.php b/tiny-compress-images.php index 6576e7dc..16f1c19e 100644 --- a/tiny-compress-images.php +++ b/tiny-compress-images.php @@ -20,11 +20,14 @@ require dirname( __FILE__ ) . '/src/class-tiny-bulk-optimization.php'; require dirname( __FILE__ ) . '/src/class-tiny-image-size.php'; require dirname( __FILE__ ) . '/src/class-tiny-image.php'; +require dirname( __FILE__ ) . '/src/class-tiny-server-capabilities.php'; require dirname( __FILE__ ) . '/src/class-tiny-settings.php'; require dirname( __FILE__ ) . '/src/class-tiny-plugin.php'; require dirname( __FILE__ ) . '/src/class-tiny-notices.php'; require dirname( __FILE__ ) . '/src/class-tiny-cli.php'; +require dirname( __FILE__ ) . '/src/class-tiny-conversion.php'; require dirname( __FILE__ ) . '/src/class-tiny-picture.php'; +require dirname( __FILE__ ) . '/src/class-tiny-apache-rewrite.php'; require dirname( __FILE__ ) . '/src/compatibility/wpml/class-tiny-wpml.php'; require dirname( __FILE__ ) . '/src/compatibility/as3cf/class-tiny-as3cf.php'; require dirname( __FILE__ ) . '/src/compatibility/woocommerce/class-tiny-woocommerce.php'; diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 00000000..79745b95 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,14 @@ +