From ae2d0d7fe8cd3bb7107d5fa9937669c8bc7ed5f0 Mon Sep 17 00:00:00 2001 From: Bowen Date: Tue, 31 Mar 2026 15:57:06 +0800 Subject: [PATCH] feat(favicon): Use feed-provided icon URL (, Atom icon/logo, JSON Feed icon) (#8633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * prefer feed.icon Closes #5518 Changes proposed in this pull request: - When a feed provides an icon URL ( in RSS 2.0/1.0, / in Atom, icon/favicon fields in JSON Feed), that URL is stored as a feedIconUrl attribute on the feed and used as the primary source for favicon downloads, instead of scraping the feed's website for tags. - If the feed-provided icon URL fails to return a valid image, the existing fallback chain (website HTML favicon search → /favicon.ico) is preserved. Custom favicons uploaded by users always take priority and are never overridden. How to test the feature manually: 1. Add an RSS feed that includes a element (e.g. an RSSHub feed: `https://rsshub.app/youtube/channel/UC2cRwTuSWxxEtrRnT4lrlQA`). After actualization, confirm the feed's favicon matches the avatar image from the feed, not the Bilibili site favicon. 2. Add an Atom feed containing or Confirm the feed icon is used. 3. Add a JSON Feed (spec: icon field). Confirm icon is preferred over favicon when both are present. 4. Temporarily point a feed's to a broken URL. Confirm FreshRSS falls back to the website favicon silently. 5. Upload a custom favicon for a feed, then actualize it. Confirm the custom favicon is not replaced. image * fix(favicon): use htmlspecialchars_decode for feed image URL * Decode quotes as well * New function in our SimplePie fork https://github.com/FreshRSS/simplepie/pull/73 --------- Co-authored-by: Alexandre Alapetite --- app/Controllers/feedController.php | 8 ++++++++ app/Models/Feed.php | 21 +++++++++++++++------ app/Utils/dotNotationUtil.php | 8 ++++++++ lib/composer.json | 2 +- lib/favicons.php | 16 ++++++++++++++++ lib/simplepie/simplepie/src/SimplePie.php | 23 ++++++++++++++++++++++- p/f.php | 4 +++- 7 files changed, 73 insertions(+), 9 deletions(-) diff --git a/app/Controllers/feedController.php b/app/Controllers/feedController.php index 9a6a7daa6..f3a7edea3 100644 --- a/app/Controllers/feedController.php +++ b/app/Controllers/feedController.php @@ -744,6 +744,14 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { } if ($simplePie != null) { + $feedImageUrl = htmlspecialchars_decode($simplePie->get_icon_url() ?? '', ENT_QUOTES); + $feedImageUrl = $feedImageUrl !== '' ? (FreshRSS_http_Util::checkUrl($feedImageUrl) ?: '') : ''; + if ($feedImageUrl !== ($feed->attributeString('feedIconUrl') ?? '')) { + $feed->_attribute('feedIconUrl', $feedImageUrl !== '' ? $feedImageUrl : null); + $feed->resetFaviconHash(); + $feedProperties['attributes'] = $feed->attributes(); + } + if ($feed->name(true) === '') { //HTML to HTML-PRE //ENT_COMPAT except '&' $name = strtr(html_only_entity_decode($simplePie->get_title()), ['<' => '<', '>' => '>', '"' => '"']); diff --git a/app/Models/Feed.php b/app/Models/Feed.php index acbe11fe9..1c68c396d 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -258,7 +258,9 @@ class FreshRSS_Feed extends Minz_Model { $hookParams = Minz_ExtensionManager::callHook(Minz_HookType::CustomFaviconHash, $this); $params = $hookParams !== null ? $hookParams : $current; } else { - $params = $this->website(fallback: true) . $this->proxyParam(); + $feedIconUrl = $this->attributeString('feedIconUrl') ?? ''; + $params = $feedIconUrl !== '' ? $feedIconUrl . $this->proxyParam() + : $this->website(fallback: true) . $this->proxyParam(); } $this->hashFavicon = hash('crc32b', $salt . (is_string($params) ? $params : '')); } @@ -399,11 +401,13 @@ class FreshRSS_Feed extends Minz_Model { if ($this->customFavicon()) { return; } - $url = $this->website(fallback: false); - if ($url === '' || $url === $this->url) { + $feedIconUrl = $this->attributeString('feedIconUrl') ?? ''; + $websiteUrl = $this->website(fallback: false); + if ($websiteUrl === '' || $websiteUrl === $this->url) { // Get root URL from the feed URL - $url = preg_replace('%^(https?://[^/]+).*$%i', '$1/', $this->url) ?? $this->url; + $websiteUrl = preg_replace('%^(https?://[^/]+).*$%i', '$1/', $this->url) ?? $this->url; } + $url = $feedIconUrl !== '' ? $feedIconUrl : $websiteUrl; $txt = FAVICONS_DIR . $this->hashFavicon() . '.txt'; if (@file_get_contents($txt) !== $url) { @@ -416,8 +420,11 @@ class FreshRSS_Feed extends Minz_Model { if ($txt_mtime != false && ($ico_mtime == false || $ico_mtime < $txt_mtime || ($ico_mtime < time() - (14 * 86400)))) { // no ico file or we should download a new one. - $url = file_get_contents($txt); - if ($url == false || !download_favicon($url, $ico)) { + if ($feedIconUrl !== '' && download_favicon_from_image_url($feedIconUrl, $ico)) { + return; + } + // Fall back to website favicon search + if (!download_favicon($websiteUrl, $ico)) { touch($ico); } } @@ -908,6 +915,8 @@ class FreshRSS_Feed extends Minz_Model { private function dotNotationForStandardJsonFeed(): array { return [ 'feedTitle' => 'title', + 'feedImage' => 'icon', + 'feedImageFallback' => 'favicon', 'item' => 'items', 'itemTitle' => 'title', 'itemContent' => 'content_text', diff --git a/app/Utils/dotNotationUtil.php b/app/Utils/dotNotationUtil.php index a8dab260f..95ed214e8 100644 --- a/app/Utils/dotNotationUtil.php +++ b/app/Utils/dotNotationUtil.php @@ -137,6 +137,14 @@ final class FreshRSS_dotNotation_Util ? (htmlspecialchars(FreshRSS_dotNotation_Util::getString($jf, $dotNotation['feedTitle']) ?? '', ENT_COMPAT, 'UTF-8') ?: $defaultRssTitle) : $defaultRssTitle; + $imageUrl = isset($dotNotation['feedImage']) + ? (FreshRSS_dotNotation_Util::getString($jf, $dotNotation['feedImage']) ?? '') + : ''; + if ($imageUrl === '' && isset($dotNotation['feedImageFallback'])) { + $imageUrl = FreshRSS_dotNotation_Util::getString($jf, $dotNotation['feedImageFallback']) ?? ''; + } + $view->image_url = htmlspecialchars($imageUrl, ENT_COMPAT, 'UTF-8'); + $jsonItems = FreshRSS_dotNotation_Util::get($jf, $dotNotation['item']); if (!is_array($jsonItems) || count($jsonItems) === 0) { return null; diff --git a/lib/composer.json b/lib/composer.json index 9da6300cd..8e16dd0d5 100644 --- a/lib/composer.json +++ b/lib/composer.json @@ -14,7 +14,7 @@ "marienfressinaud/lib_opml": "0.5.1", "phpgt/cssxpath": "v1.5.0", "phpmailer/phpmailer": "7.0.2", - "simplepie/simplepie": "dev-freshrss#6405099830e5383fc2cb9aa1be7a8f42a18cb21c" + "simplepie/simplepie": "dev-merge-get_icon_url#23119820c414c6117e98c870a13727d2d41f3992" }, "config": { "sort-packages": true, diff --git a/lib/favicons.php b/lib/favicons.php index 7c12a842e..fd4a25fde 100644 --- a/lib/favicons.php +++ b/lib/favicons.php @@ -80,6 +80,22 @@ function searchFavicon(string $url): string { return ''; } +/** + * Downloads a favicon directly from a known image URL (e.g. from a feed's or icon field). + * Returns false without any fallback if the URL does not point to a valid image. + */ +function download_favicon_from_image_url(string $imageUrl, string $dest): bool { + $imageUrl = trim($imageUrl); + if ($imageUrl === '') { + return false; + } + $favicon = FreshRSS_http_Util::httpGet($imageUrl, faviconCachePath($imageUrl), 'ico')['body']; + if (!isImgMime($favicon)) { + return false; + } + return file_put_contents($dest, $favicon) > 0; +} + function download_favicon(string $url, string $dest): bool { $url = trim($url); $favicon = searchFavicon($url); diff --git a/lib/simplepie/simplepie/src/SimplePie.php b/lib/simplepie/simplepie/src/SimplePie.php index 9bd65f892..aa9d2782f 100644 --- a/lib/simplepie/simplepie/src/SimplePie.php +++ b/lib/simplepie/simplepie/src/SimplePie.php @@ -3224,6 +3224,28 @@ class SimplePie return null; } + /** + * Get the feed icon's URL + * + * Returns favicon-like feed artwork only. + * + * Uses ``, or RSS 2.0 `` (only if square). + * + * @return string|null + */ + public function get_icon_url() + { + if ($return = $this->get_channel_tags(self::NAMESPACE_ATOM_10, 'icon')) { + return $this->sanitize($return[0]['data'], self::CONSTRUCT_IRI, $this->get_base($return[0])); + } elseif (($return = $this->get_image_tags(self::NAMESPACE_RSS_20, 'url')) && + ($this->get_image_width() ?? -2) === ($this->get_image_height() ?? -3)) { + // Use only if the image is square, otherwise it is likely a banner and not an icon + return $this->sanitize($return[0]['data'], self::CONSTRUCT_IRI, $this->get_base($return[0])); + } + + return null; + } + /** * Get the feed logo's title * @@ -3280,7 +3302,6 @@ class SimplePie return null; } - /** * Get the feed logo's link * diff --git a/p/f.php b/p/f.php index 4777ab28b..63e2060d0 100644 --- a/p/f.php +++ b/p/f.php @@ -52,7 +52,9 @@ if (($ico_mtime == false || $ico_mtime < $txt_mtime || ($ico_mtime < time() - (r exit(); } - if (!download_favicon($url, $ico)) { + // Try downloading the URL as a direct image first (e.g. from a feed's ), + // then fall back to HTML favicon search if it is not a valid image. + if (!download_favicon_from_image_url($url, $ico) && !download_favicon($url, $ico)) { // Download failed if ($ico_mtime == false) { show_default_favicon(86400);