Selaa lähdekoodia

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>
Bowen 2 päivää sitten
vanhempi
commit
ae2d0d7fe8

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

+ 15 - 6
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',

+ 8 - 0
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;

+ 1 - 1
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,

+ 16 - 0
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 <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);

+ 22 - 1
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 `<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
      *

+ 3 - 1
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);