mirror of
https://github.com/FreshRSS/FreshRSS.git
synced 2026-04-16 12:27:20 -04:00
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:
@@ -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()), ['<' => '<', '>' => '>', '"' => '"']);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
4
p/f.php
4
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 <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);
|
||||
|
||||
Reference in New Issue
Block a user