From edd85686c4e3c848cb51318da444aba8811ef03e Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Tue, 23 Aug 2022 17:46:46 +0000 Subject: [PATCH] Themes: Add support for `Update URI` header. This allows third-party themes to avoid accidentally being overwritten with an update of a theme of a similar name from the WordPress.org Theme Directory. Additionally, introduce the `update_themes_{$hostname}` filter, which third-party themes can use to offer updates for a given hostname. If set, the `Update URI` header field should be a URI and have a unique hostname. Some examples include: * `https://wordpress.org/themes/example-theme/` * `https://example.com/my-theme/` * `my-custom-theme-name` `Update URI: false` also works, and unless there is code handling the `false` hostname, the theme will never get an update notification. If the header is present, the WordPress.org API will currently only return updates for the theme if it matches the following format: * `https://wordpress.org/themes/{$slug}/` * `w.org/theme/{$slug}` If the header has any other value, the API will not return a result and will ignore the theme for update purposes. Follow-up to [50921]. Props dd32, meloniq, costdev, audrasjb, DavidAnderson, markjaquith, DrewAPicture, mweichert, design_dolphin, filosofo, sean212, nhuja, JeroenReumkens, infolu, dingdang, joyously, earnjam, williampatton, grapplerulrich, markparnell, apedog, afragen, miqrogroove, rmccue, crazycoders, jdgrimes, damonganto, joostdevalk, jorbin, georgestephanis, khromov, GeekStreetWP, jb510, Rarst, juliobox, Ipstenu, mikejolley, Otto42, gMagicScott, TJNowell, GaryJ, knutsp, mordauk, nvartolomei, aspexi, chriscct7, benoitchantre, ryno267, lev0, gregorlove, dougwollison, leemon, SergeyBiryukov. See #14179, #23318, #32101. git-svn-id: https://develop.svn.wordpress.org/trunk@53933 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-theme.php | 7 +- src/wp-includes/update.php | 89 ++++++++++++++++++- .../data/themedir1/update-uri-theme/index.php | 4 + .../data/themedir1/update-uri-theme/style.css | 9 ++ tests/phpunit/tests/theme/themeDir.php | 1 + tests/phpunit/tests/theme/wpTheme.php | 46 ++++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/phpunit/data/themedir1/update-uri-theme/index.php create mode 100644 tests/phpunit/data/themedir1/update-uri-theme/style.css diff --git a/src/wp-includes/class-wp-theme.php b/src/wp-includes/class-wp-theme.php index fe74b16108..55a9359a13 100644 --- a/src/wp-includes/class-wp-theme.php +++ b/src/wp-includes/class-wp-theme.php @@ -23,6 +23,7 @@ final class WP_Theme implements ArrayAccess { * * @since 3.4.0 * @since 5.4.0 Added `Requires at least` and `Requires PHP` headers. + * @since 6.1.0 Added `Update URI` header. * @var string[] */ private static $file_headers = array( @@ -39,6 +40,7 @@ final class WP_Theme implements ArrayAccess { 'DomainPath' => 'Domain Path', 'RequiresWP' => 'Requires at least', 'RequiresPHP' => 'Requires PHP', + 'UpdateURI' => 'Update URI', ); /** @@ -844,9 +846,11 @@ final class WP_Theme implements ArrayAccess { * * @since 3.4.0 * @since 5.4.0 Added support for `Requires at least` and `Requires PHP` headers. + * @since 6.1.0 Added support for `Update URI` header. * * @param string $header Theme header. Accepts 'Name', 'Description', 'Author', 'Version', - * 'ThemeURI', 'AuthorURI', 'Status', 'Tags', 'RequiresWP', 'RequiresPHP'. + * 'ThemeURI', 'AuthorURI', 'Status', 'Tags', 'RequiresWP', 'RequiresPHP', + * 'UpdateURI'. * @param string $value Value to sanitize. * @return string|array An array for Tags header, string otherwise. */ @@ -896,6 +900,7 @@ final class WP_Theme implements ArrayAccess { case 'Version': case 'RequiresWP': case 'RequiresPHP': + case 'UpdateURI': $value = strip_tags( $value ); break; } diff --git a/src/wp-includes/update.php b/src/wp-includes/update.php index bc295359bf..18465186d1 100644 --- a/src/wp-includes/update.php +++ b/src/wp-includes/update.php @@ -505,7 +505,7 @@ function wp_update_plugins( $extra_stats = array() ) { * } * @param array $plugin_data Plugin headers. * @param string $plugin_file Plugin filename. - * @param array $locales Installed locales to look translations for. + * @param array $locales Installed locales to look up translations for. */ $update = apply_filters( "update_plugins_{$hostname}", false, $plugin_data, $plugin_file, $locales ); @@ -613,6 +613,7 @@ function wp_update_themes( $extra_stats = array() ) { 'Version' => $theme->get( 'Version' ), 'Author' => $theme->get( 'Author' ), 'Author URI' => $theme->get( 'AuthorURI' ), + 'UpdateURI' => $theme->get( 'UpdateURI' ), 'Template' => $theme->get_template(), 'Stylesheet' => $theme->get_stylesheet(), ); @@ -744,6 +745,92 @@ function wp_update_themes( $extra_stats = array() ) { $new_update->translations = $response['translations']; } + // Support updates for any themes using the `Update URI` header field. + foreach ( $themes as $theme_stylesheet => $theme_data ) { + if ( ! $theme_data['UpdateURI'] || isset( $new_update->response[ $theme_stylesheet ] ) ) { + continue; + } + + $hostname = wp_parse_url( esc_url_raw( $theme_data['UpdateURI'] ), PHP_URL_HOST ); + + /** + * Filters the update response for a given theme hostname. + * + * The dynamic portion of the hook name, `$hostname`, refers to the hostname + * of the URI specified in the `Update URI` header field. + * + * @since 6.1.0 + * + * @param array|false $update { + * The theme update data with the latest details. Default false. + * + * @type string $id Optional. ID of the theme for update purposes, should be a URI + * specified in the `Update URI` header field. + * @type string $theme Directory name of the theme. + * @type string $version The version of the theme. + * @type string $url The URL for details of the theme. + * @type string $package Optional. The update ZIP for the theme. + * @type string $tested Optional. The version of WordPress the theme is tested against. + * @type string $requires_php Optional. The version of PHP which the theme requires. + * @type bool $autoupdate Optional. Whether the theme should automatically update. + * @type array $translations { + * Optional. List of translation updates for the theme. + * + * @type string $language The language the translation update is for. + * @type string $version The version of the theme this translation is for. + * This is not the version of the language file. + * @type string $updated The update timestamp of the translation file. + * Should be a date in the `YYYY-MM-DD HH:MM:SS` format. + * @type string $package The ZIP location containing the translation update. + * @type string $autoupdate Whether the translation should be automatically installed. + * } + * } + * @param array $theme_data Theme headers. + * @param string $theme_stylesheet Theme stylesheet. + * @param array $locales Installed locales to look up translations for. + */ + $update = apply_filters( "update_themes_{$hostname}", false, $theme_data, $theme_stylesheet, $locales ); + + if ( ! $update ) { + continue; + } + + $update = (object) $update; + + // Is it valid? We require at least a version. + if ( ! isset( $update->version ) ) { + continue; + } + + // This should remain constant. + $update->id = $theme_data['UpdateURI']; + + // WordPress needs the version field specified as 'new_version'. + if ( ! isset( $update->new_version ) ) { + $update->new_version = $update->version; + } + + // Handle any translation updates. + if ( ! empty( $update->translations ) ) { + foreach ( $update->translations as $translation ) { + if ( isset( $translation['language'], $translation['package'] ) ) { + $translation['type'] = 'theme'; + $translation['slug'] = isset( $update->theme ) ? $update->theme : $update->id; + + $new_update->translations[] = $translation; + } + } + } + + unset( $new_update->no_update[ $theme_stylesheet ], $new_update->response[ $theme_stylesheet ] ); + + if ( version_compare( $update->new_version, $theme_data['Version'], '>' ) ) { + $new_update->response[ $theme_stylesheet ] = (array) $update; + } else { + $new_update->no_update[ $theme_stylesheet ] = (array) $update; + } + } + set_site_transient( 'update_themes', $new_update ); } diff --git a/tests/phpunit/data/themedir1/update-uri-theme/index.php b/tests/phpunit/data/themedir1/update-uri-theme/index.php new file mode 100644 index 0000000000..81f55f2fda --- /dev/null +++ b/tests/phpunit/data/themedir1/update-uri-theme/index.php @@ -0,0 +1,4 @@ +assertSameSets( $expected, $theme_names ); diff --git a/tests/phpunit/tests/theme/wpTheme.php b/tests/phpunit/tests/theme/wpTheme.php index 8807c0b9e1..0ded32d77b 100644 --- a/tests/phpunit/tests/theme/wpTheme.php +++ b/tests/phpunit/tests/theme/wpTheme.php @@ -405,4 +405,50 @@ class Tests_Theme_wpTheme extends WP_UnitTestCase { ), ); } + + /** + * Tests that the UpdateURI header is retrieved. + * + * @ticket 14179 + * + * @covers WP_Theme::get + */ + public function test_theme_get_update_uri_header() { + $theme = new WP_Theme( 'update-uri-theme', $this->theme_root ); + + $this->assertTrue( + $theme->exists(), + 'The update-uri-theme does not exist.' + ); + + $update_uri = $theme->get( 'UpdateURI' ); + + $this->assertIsString( + $update_uri, + 'The UpdateURI header was not returned as a string.' + ); + + $this->assertSame( + 'http://example.org/update-uri-theme/', + $update_uri, + 'The UpdateURI header did not match the expected value.' + ); + } + + /** + * Tests that WP_Theme::sanitize_header() strips tags from the UpdateURI header. + * + * @ticket 14179 + * + * @covers WP_Theme::sanitize_header + */ + public function test_should_strip_tags_from_update_uri_header() { + $theme = new WP_Theme( 'twentytwentytwo', $this->theme_root ); + $sanitize_header = new ReflectionMethod( $theme, 'sanitize_header' ); + $sanitize_header->setAccessible( true ); + + $actual = $sanitize_header->invoke( $theme, 'UpdateURI', 'http://example.org' ); + + $this->assertSame( 'http://example.org', $actual ); + } }