Files
FreshRSS/app/Utils/dotNotationUtil.php
Bowen ae2d0d7fe8 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>
2026-03-31 09:57:06 +02:00

239 lines
9.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
final class FreshRSS_dotNotation_Util
{
/**
* Get an item from an array using "dot" notation.
* Functions adapted from https://stackoverflow.com/a/39118759
* https://github.com/illuminate/support/blob/52e8f314b8043860b1c09e5c2c7e8cca94aafc7d/Arr.php#L270-L305
* Newer version in
* https://github.com/laravel/framework/blob/10.x/src/Illuminate/Collections/Arr.php#L302-L337
*
* @param \ArrayAccess<string,mixed>|array<string,mixed>|mixed $array
*/
public static function get($array, ?string $key, mixed $default = null): mixed {
if (!static::accessible($array)) {
return static::value($default);
}
/** @var \ArrayAccess<string,mixed>|array<string,mixed> $array */
if ($key === null) {
return $array;
}
$key = trim($key);
if (in_array($key, ['', '.', '$'], true)) {
return $array;
}
// If the key is a simple string, return the text
if (preg_match('/^(?P<delim>[\'"])(?P<text>[^&]*)(?P=delim)$/', $key, $matches)) {
$text = $matches['text'];
$text = str_replace('', '&', $text); // Unescape `&`
return $text;
}
// Escape `&` operator
$key = preg_replace_callback('/(?P<delim>[\'"])(?P<text>.*?)(?P=delim)/',
fn(array $matches): string => str_replace('&', '', $matches[0]),
$key) ?? $key;
// If the key contains string concatenations with `&`, process them
$concats = explode('&', $key);
if (count($concats) > 1) {
$text = '';
foreach ($concats as $concat) {
$result = static::get($array, $concat, $default);
if (is_scalar($result)) {
$text .= (string)$result;
}
}
return $text;
}
// Compatibility with brackets path such as `items[0].value`
$key = preg_replace('/\[(\d+)\]/', '.$1', $key);
if ($key === null) {
return null;
}
if (static::exists($array, $key)) {
return $array[$key];
}
if (str_contains($key, '.') === false) {
return $array[$key] ?? static::value($default);
}
foreach (explode('.', $key) as $segment) {
if (static::accessible($array) && static::exists($array, $segment)) {
$array = $array[$segment];
} else {
return static::value($default);
}
}
return $array;
}
/**
* Get a string from an array using "dot" notation.
*
* @param \ArrayAccess<string,mixed>|array<string,mixed>|mixed $array
*/
public static function getString($array, ?string $key): ?string {
$result = self::get($array, $key, null);
return is_string($result) || is_bool($result) || is_float($result) || is_int($result) ? (string)$result : null;
}
/**
* Determine whether the given value is array accessible.
*/
private static function accessible(mixed $value): bool {
return is_array($value) || $value instanceof \ArrayAccess;
}
/**
* Determine if the given key exists in the provided array.
*
* @param \ArrayAccess<string,mixed>|array<string,mixed>|mixed $array
* @phpstan-assert-if-true \ArrayAccess<string,mixed>|array<string,mixed> $array
*/
private static function exists($array, string $key): bool {
if ($array instanceof \ArrayAccess) {
return $array->offsetExists($key);
}
if (is_array($array)) {
return array_key_exists($key, $array);
}
return false;
}
private static function value(mixed $value): mixed {
return $value instanceof Closure ? $value() : $value;
}
/**
* Convert a JSON object to a RSS document
* mapping fields from the JSON object into RSS equivalents
* according to the dot-separated paths
*
* @param array<int|string,mixed> $jf json feed
* @param string $feedSourceUrl the source URL for the feed
* @param array<string,string> $dotNotation dot notation to map JSON into RSS
* @param string $defaultRssTitle Default title of the RSS feed, if not already provided in dotNotation `feedTitle`
*/
public static function convertJsonToRss(array $jf, string $feedSourceUrl, array $dotNotation, string $defaultRssTitle = ''): ?string {
if (!isset($dotNotation['item']) || $dotNotation['item'] === '') {
return null; //no definition of item path, but we can't scrape anything without knowing this
}
$view = new FreshRSS_View();
$view->_path('index/rss.phtml');
$view->internal_rendering = true;
$view->rss_url = htmlspecialchars($feedSourceUrl, ENT_COMPAT, 'UTF-8');
$view->html_url = $view->rss_url;
$view->entries = [];
$view->rss_title = isset($dotNotation['feedTitle'])
? (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;
}
foreach ($jsonItems as $jsonItem) {
$rssItem = [];
$rssItem['link'] = isset($dotNotation['itemUri']) ? FreshRSS_dotNotation_Util::getString($jsonItem, $dotNotation['itemUri']) ?? '' : '';
if (empty($rssItem['link'])) {
continue;
}
$rssItem['title'] = isset($dotNotation['itemTitle']) ? FreshRSS_dotNotation_Util::getString($jsonItem, $dotNotation['itemTitle']) ?? '' : '';
$rssItem['author'] = isset($dotNotation['itemAuthor']) ? FreshRSS_dotNotation_Util::getString($jsonItem, $dotNotation['itemAuthor']) ?? '' : '';
$rssItem['timestamp'] = isset($dotNotation['itemTimestamp']) ? FreshRSS_dotNotation_Util::getString($jsonItem, $dotNotation['itemTimestamp']) ?? '' : '';
//get simple content, but if a path for HTML content has been provided, replace the simple content with HTML content
$rssItem['content'] = isset($dotNotation['itemContent']) ? FreshRSS_dotNotation_Util::getString($jsonItem, $dotNotation['itemContent']) ?? '' : '';
$rssItem['content'] = isset($dotNotation['itemContentHTML'])
? FreshRSS_dotNotation_Util::getString($jsonItem, $dotNotation['itemContentHTML']) ?? ''
: $rssItem['content'];
if (isset($dotNotation['itemTimeFormat']) && is_string($dotNotation['itemTimeFormat'])) {
if ($dotNotation['itemTimeFormat'] === 'U' && strlen($rssItem['timestamp']) > 10) {
// Compatibility with Unix timestamp in milliseconds
$rssItem['timestamp'] = substr($rssItem['timestamp'], 0, -3);
}
$dateTime = DateTime::createFromFormat($dotNotation['itemTimeFormat'], $rssItem['timestamp']);
if ($dateTime != false) {
$rssItem['timestamp'] = $dateTime->format(DateTime::ATOM);
}
}
if (isset($dotNotation['itemCategories'])) {
$jsonItemCategories = FreshRSS_dotNotation_Util::get($jsonItem, $dotNotation['itemCategories']);
if (is_string($jsonItemCategories) && $jsonItemCategories !== '') {
$rssItem['tags'] = [$jsonItemCategories];
} elseif (is_array($jsonItemCategories) && count($jsonItemCategories) > 0) {
$rssItem['tags'] = [];
foreach ($jsonItemCategories as $jsonItemCategory) {
if (is_string($jsonItemCategory)) {
$rssItem['tags'][] = $jsonItemCategory;
}
}
}
}
$rssItem['thumbnail'] = isset($dotNotation['itemThumbnail']) ? FreshRSS_dotNotation_Util::getString($jsonItem, $dotNotation['itemThumbnail']) ?? '' : '';
//Enclosures?
if (isset($dotNotation['itemAttachment'])) {
$jsonItemAttachments = FreshRSS_dotNotation_Util::get($jsonItem, $dotNotation['itemAttachment']);
if (is_array($jsonItemAttachments) && count($jsonItemAttachments) > 0) {
$rssItem['attachments'] = [];
foreach ($jsonItemAttachments as $attachment) {
$rssAttachment = [];
$rssAttachment['url'] = isset($dotNotation['itemAttachmentUrl'])
? FreshRSS_dotNotation_Util::getString($attachment, $dotNotation['itemAttachmentUrl'])
: '';
$rssAttachment['type'] = isset($dotNotation['itemAttachmentType'])
? FreshRSS_dotNotation_Util::getString($attachment, $dotNotation['itemAttachmentType'])
: '';
$rssAttachment['length'] = isset($dotNotation['itemAttachmentLength'])
? FreshRSS_dotNotation_Util::get($attachment, $dotNotation['itemAttachmentLength'])
: '';
$rssItem['attachments'][] = $rssAttachment;
}
}
}
if (isset($dotNotation['itemUid'])) {
$rssItem['guid'] = FreshRSS_dotNotation_Util::getString($jsonItem, $dotNotation['itemUid']);
}
if (empty($rssItem['guid'])) {
$rssItem['guid'] = 'urn:sha1:' . sha1($rssItem['title'] . $rssItem['content'] . $rssItem['link']);
}
if ($rssItem['title'] != '' || $rssItem['content'] != '' || $rssItem['link'] != '') {
// HTML-encoding/escaping of the relevant fields (all except 'content')
foreach (['author', 'guid', 'link', 'thumbnail', 'timestamp', 'tags', 'title'] as $key) {
if (!empty($rssItem[$key]) && is_string($rssItem[$key])) {
$rssItem[$key] = Minz_Helper::htmlspecialchars_utf8($rssItem[$key]);
}
}
$view->entries[] = FreshRSS_Entry::fromArray($rssItem);
}
}
return $view->renderToString();
}
}