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