feat(favicon): Use feed-provided icon URL (<image><url>, Atom icon/logo, JSON Feed icon) (#8633)

* prefer feed.icon
Closes #5518

Changes proposed in this pull request:

- When a feed provides an icon URL (<image><url> in RSS 2.0/1.0, <atom:icon>/<atom:logo> 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 <link rel="icon"> 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 <image><url> 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 <atom:icon> or <atom:logo> 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 <image><url> 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.

<img width="470" height="317" alt="image" src="https://github.com/user-attachments/assets/17445154-d94c-44d6-b7e7-019bf24c5767" />

* 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 <alexandre@alapetite.fr>
This commit is contained in:
Bowen
2026-03-31 15:57:06 +08:00
committed by GitHub
parent 61f3151f7f
commit ae2d0d7fe8
7 changed files with 73 additions and 9 deletions

View File

@@ -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()), ['<' => '&lt;', '>' => '&gt;', '"' => '&quot;']);

View File

@@ -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',

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 <image><url> 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);

View File

@@ -3224,6 +3224,28 @@ class SimplePie
return null;
}
/**
* Get the feed icon's URL
*
* Returns favicon-like feed artwork only.
*
* Uses `<atom:icon>`, or RSS 2.0 `<image><url>` (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
*

View File

@@ -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 <image><url>),
// 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);