From 011e1d2cac723b72338dd096f50b7e2e230cf886 Mon Sep 17 00:00:00 2001 From: alexandergull Date: Fri, 25 Sep 2026 17:16:24 +0500 Subject: [PATCH 1/3] New. Plugins page. Show changelog in the update notice. https://app.doboard.com/1/task/46119 --- inc/spbc-admin.php | 3 + .../Common/AbstractUpdateChangelogNotice.php | 607 ++++++++++++++++++ lib/CleantalkSP/SpbctWP/Escape.php | 20 + .../SpbctWP/UpdateChangelogNotice.php | 65 ++ 4 files changed, 695 insertions(+) create mode 100644 lib/CleantalkSP/Common/AbstractUpdateChangelogNotice.php create mode 100644 lib/CleantalkSP/SpbctWP/UpdateChangelogNotice.php diff --git a/inc/spbc-admin.php b/inc/spbc-admin.php index cff800041..51a7fbdad 100644 --- a/inc/spbc-admin.php +++ b/inc/spbc-admin.php @@ -27,6 +27,7 @@ use CleantalkSP\SpbctWP\UsersPassCheckModule\UsersPassCheckHandler; use CleantalkSP\SpbctWP\Scanner\ScannerAjaxEndpoints; use CleantalkSP\SpbctWP\Scanner\ScannerActions\BackupsActions; +use CleantalkSP\SpbctWP\UpdateChangelogNotice; // Prevent direct call if ( ! defined('ABSPATH') ) { @@ -300,6 +301,8 @@ function spbc_plugin_action_links($links) return $links; } +UpdateChangelogNotice::register(); + add_action('after_plugin_row', 'spbc_plugin_list_show_vulnerability', 20, 3); function spbc_plugin_list_show_vulnerability($plugin_file, $plugin_data, $_status) { diff --git a/lib/CleantalkSP/Common/AbstractUpdateChangelogNotice.php b/lib/CleantalkSP/Common/AbstractUpdateChangelogNotice.php new file mode 100644 index 000000000..23a414999 --- /dev/null +++ b/lib/CleantalkSP/Common/AbstractUpdateChangelogNotice.php @@ -0,0 +1,607 @@ +getPluginFile()); + } + + /** + * "Owner/repo" used as a fallback source. Return null to disable the fallback. + * + * @return string|null + */ + protected function getGithubRepo() + { + return null; + } + + /** + * Entry point. Wraps the native update row to inject the changelog + * into the very same notice block. + * + * @return void + */ + public static function register() + { + $instance = new static(); + $hook = 'after_plugin_row_' . $instance->getPluginFile(); + + // wp_plugin_update_row() is hooked with priority 10, so the row output + // is captured and post-processed around it. + add_action($hook, array($instance, 'startRowBuffer'), 9, 3); + add_action($hook, array($instance, 'flushRowBuffer'), 11, 3); + } + + /** + * Hook handler. Starts capturing the native update row output. + * + * @return void + */ + public function startRowBuffer() + { + ob_start(); + } + + /** + * Hook handler. Injects the changelog into the captured update row. + * + * @param string $plugin_file + * + * @return void + */ + public function flushRowBuffer($plugin_file = '') + { + $row = ob_get_clean(); + + if ( ! is_string($row) ) { + return; + } + + echo $this->injectNotice($row, $plugin_file !== '' ? $plugin_file : $this->getPluginFile()); + } + + /** + * Places the changelog markup right before the end of the notice container. + * + * @param string $row captured update row markup + * @param string $plugin_file + * + * @return string + */ + protected function injectNotice($row, $plugin_file) + { + if ( $row === '' || strpos($row, 'update-message') === false ) { + return $row; + } + + $version = $this->getOfferedVersion($plugin_file); + + if ( $version === '' ) { + return $row; + } + + $changelog_html = $this->getChangelogHtml($version); + + if ( $changelog_html === '' ) { + return $row; + } + + $notice_html = $this->getNoticeHtml($version, $changelog_html); + + if ( ! is_string($notice_html) || $notice_html === '' ) { + return $row; + } + + $position = strrpos($row, '

'); + + if ( $position === false ) { + return $row; + } + + return substr_replace($row, '

' . $notice_html . '', $position, strlen('

')); + } + + /** + * Version offered by the WordPress updates transient. + * + * @param string $plugin_file + * + * @return string empty string when no update is available + */ + protected function getOfferedVersion($plugin_file) + { + $updates = get_site_transient('update_plugins'); + + return isset($updates->response[$plugin_file]->new_version) + ? (string)$updates->response[$plugin_file]->new_version + : ''; + } + + /** + * Returns the sanitized changelog of the given version. Result is cached, + * including the negative one, to avoid hammering remote sources. + * + * @param string $version + * + * @return string empty string when the changelog can not be obtained + */ + protected function getChangelogHtml($version) + { + $cache_key = static::CACHE_PREFIX . md5($this->getSlug() . '|' . $version); + $cached = $this->getCache($cache_key); + + if ( $cached !== false ) { + return is_string($cached) ? $cached : ''; + } + + $html = $this->fetchFromWpOrg($version); + + if ( $html === '' ) { + $html = $this->fetchFromGitHub($version); + } + + $this->setCache( + $cache_key, + $html, + $html !== '' ? static::CACHE_TTL_SUCCESS : static::CACHE_TTL_FAIL + ); + + return $html; + } + + /** + * Primary source: WordPress.org plugin information API. + * + * @param string $version + * + * @return string + */ + protected function fetchFromWpOrg($version) + { + $info = $this->requestPluginInformation(); + + if ( is_wp_error($info) ) { + return ''; + } + + $info = (array) $info; + $sections = $info['sections'] ?? []; + + if ( ! is_array($sections) ) { + return ''; + } + + $changelog = $sections['changelog'] ?? ''; + + if ( ! is_string($changelog) || $changelog === '' ) { + return ''; + } + + return $this->extractVersionBlock($changelog, $version); + } + + /** + * Fallback source: GitHub release notes of the tag equal to the offered version. + * + * @param string $version + * + * @return string + */ + protected function fetchFromGitHub($version) + { + $repo = $this->getGithubRepo(); + + if ( ! is_string($repo) || $repo === '' ) { + return ''; + } + + $repo_path = implode('/', array_map('rawurlencode', explode('/', $repo))); + + $urls = array( + 'https://api.github.com/repos/' . $repo_path . '/releases/tags/' . rawurlencode($version), + 'https://api.github.com/repos/' . $repo_path . '/releases?per_page=1', + ); + + foreach ( $urls as $url ) { + $body = $this->doRequest($url); + + if ( $body === '' ) { + continue; + } + + $data = json_decode($body, true); + + if ( ! is_array($data) ) { + continue; + } + + if ( isset($data['body']) ) { + $release_notes = $data['body']; + } elseif ( isset($data[0]['body']) ) { + $release_notes = $data[0]['body']; + } else { + continue; + } + + if ( is_string($release_notes) && $release_notes !== '' ) { + return $this->readmeToHtml($release_notes); + } + } + + return ''; + } + + /** + * Seam: the only call to the WordPress.org API. + * + * @return array|object|\WP_Error + */ + protected function requestPluginInformation() + { + if ( ! function_exists('plugins_api') ) { + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + } + + return plugins_api('plugin_information', array( + 'slug' => $this->getSlug(), + 'fields' => array( + 'sections' => true, + 'banners' => false, + 'screenshots' => false, + 'reviews' => false, + 'contributors' => false, + ), + )); + } + + /** + * Seam: the only outgoing HTTP request. + * + * @param string $url + * + * @return string response body or empty string on any failure + */ + protected function doRequest($url) + { + $response = wp_remote_get($url, array( + 'timeout' => static::HTTP_TIMEOUT, + 'headers' => array( + 'Accept' => 'application/vnd.github+json', + 'User-Agent' => 'WordPress/' . $this->getSlug(), + ), + )); + + if ( is_wp_error($response) || (int)wp_remote_retrieve_response_code($response) !== 200 ) { + return ''; + } + + return (string)wp_remote_retrieve_body($response); + } + + /** + * @param string $key + * + * @return mixed false when the cache is empty + */ + protected function getCache($key) + { + return get_transient($key); + } + + /** + * @param string $key + * @param string $value + * @param int $ttl + * + * @return void + */ + protected function setCache($key, $value, $ttl) + { + set_transient($key, $value, (int)$ttl); + } + + /** + * Picks a single version block from the WordPress.org changelog HTML. + * Falls back to the first (newest) block when the exact version is not found. + * + * @param string $changelog_html + * @param string $version + * + * @return string sanitized HTML + */ + protected function extractVersionBlock($changelog_html, $version) + { + $parts = preg_split( + '/(]*>.*?<\/h[1-6]>)/is', + $changelog_html, + -1, + PREG_SPLIT_DELIM_CAPTURE + ); + + if ( ! is_array($parts) ) { + return ''; + } + + $blocks = array(); + $parts_count = count($parts); + + for ( $i = 1; $i < $parts_count; $i += 2 ) { + $blocks[] = array( + 'title' => wp_strip_all_tags($parts[$i]), + 'body' => isset($parts[$i + 1]) ? $parts[$i + 1] : '', + ); + } + + if ( empty($blocks) ) { + return ''; + } + + $chosen = $blocks[0]; + + foreach ( $blocks as $block ) { + if ( preg_match('/(?sanitize($this->normalizeChangelogBody($chosen['body'])); + } + + /** + * WordPress.org returns the changelog body in two shapes: + * - a ready-made list: ""; + * - a flat block of lines separated by "
" (or plain newlines). + * + * The flat shape is normalized into a list so both variants render + * identically and never leak bare "

" tags (which would duplicate + * the native update icon rendered via ".update-message p:before"). + * + * @param string $body + * + * @return string + */ + protected function normalizeChangelogBody($body) + { + if ( ! is_string($body) || trim($body, " \f\n\r\t\v\x00") === '' ) { + return ''; + } + + // Already a list, nothing to do. + if ( stripos($body, '|\R/i', $body); + + if ( ! is_array($lines) ) { + return $body; + } + + $items = array(); + + foreach ( $lines as $line ) { + $line = trim($line, " \f\n\r\t\v\x00"); + + if ( $line === '' ) { + continue; + } + + $items[] = '

  • ' . $line . '
  • '; + } + + return $items ? '' : $body; + } + + /** + * Converts a readme-style changelog block ("= 2.188 ... =" and "* item" lines) + * to a plain list. The source text is escaped before links are generated. + * + * @param string $text + * + * @return string sanitized HTML + */ + protected function readmeToHtml($text) + { + $lines = preg_split('/\R/', $text); + + if ( ! is_array($lines) ) { + return ''; + } + + $items = array(); + + foreach ( $lines as $line ) { + $line = trim($line, " \f\n\r\t\v\x00"); + + if ( $line === '' || strpos($line, '=') === 0 || strpos($line, '#') === 0 ) { + continue; + } + + $line = ltrim($line, "*-+ \t"); + + if ( $line === '' ) { + continue; + } + + $items[] = '
  • ' . esc_html($line) . '
  • '; + } + + return $items + ? $this->sanitize('') + : ''; + } + + /** + * Strict whitelist for the third-party changelog HTML. Dangerous elements are + * dropped together with their content, the rest is escaped by the child. + * + * @param string $html + * + * @return string + */ + protected function sanitize($html) + { + if ( ! is_string($html) || $html === '' ) { + return ''; + } + + // Drop dangerous elements together with their content: + // kses strips the tags but keeps the inner text. + $stripped = preg_replace( + '#<(script|style|iframe|object|embed|svg|math|template|noscript|frameset|frame|applet)\b[^>]*>.*?#is', + '', + $html + ); + $html = is_string($stripped) ? $stripped : ''; + + $stripped = preg_replace( + '#<(script|style|iframe|object|embed|svg|math|template|noscript|frameset|frame|applet)\b[^>]*/?>#is', + '', + $html + ); + $html = is_string($stripped) ? $stripped : ''; + + return $this->escapeChangelogHtml($this->forceDiscListStyle($this->removeLinks($html))); + } + + /** + * Forces visible round bullets on every "