From 4bf678f8e4311da467aec09e4d67fe0c5f57d512 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Tue, 22 Nov 2022 08:15:22 +0100 Subject: HTML+XPath allow content as HTML (#4878) * HTML+XPath allow content as HTML #fix https://github.com/FreshRSS/FreshRSS/issues/4869 * Wrong variable reuse * Allow both HTML and expressions --- app/Models/Feed.php | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) (limited to 'app/Models/Feed.php') diff --git a/app/Models/Feed.php b/app/Models/Feed.php index f24ec1884..538814370 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -653,7 +653,23 @@ class FreshRSS_Feed extends Minz_Model { foreach ($nodes as $node) { $item = []; $item['title'] = $xPathItemTitle == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemTitle . ')', $node); - $item['content'] = $xPathItemContent == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemContent . ')', $node); + + $item['content'] = ''; + if ($xPathItemContent != '') { + $result = @$xpath->evaluate($xPathItemContent, $node); + if ($result instanceof DOMNodeList) { + // List of nodes, save as HTML + $content = ''; + foreach ($result as $child) { + $content .= $doc->saveHTML($child) . "\n"; + } + $item['content'] = $content; + } else { + // Typed expression, save as-is + $item['content'] = strval($result); + } + } + $item['link'] = $xPathItemUri == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemUri . ')', $node); $item['author'] = $xPathItemAuthor == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemAuthor . ')', $node); $item['timestamp'] = $xPathItemTimestamp == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemTimestamp . ')', $node); @@ -679,8 +695,15 @@ class FreshRSS_Feed extends Minz_Model { $item['guid'] = 'urn:sha1:' . sha1($item['title'] . $item['content'] . $item['link']); } - if ($item['title'] . $item['content'] . $item['link'] != '') { - $item = Minz_Helper::htmlspecialchars_utf8($item); + if ($item['title'] != '' || $item['content'] != '' || $item['link'] != '') { + // HTML-encoding/escaping of the relevant fields (all except 'content') + foreach (['author', 'categories', 'guid', 'link', 'thumbnail', 'timestamp', 'title'] as $key) { + if (!empty($item[$key])) { + $item[$key] = Minz_Helper::htmlspecialchars_utf8($item[$key]); + } + } + // CDATA protection + $item['content'] = str_replace(']]>', ']]>', $item['content']); $view->entries[] = FreshRSS_Entry::fromArray($item); } } -- cgit v1.2.3 From 8f9c4143fcc133f28db4c3f618649fb1170e33b4 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Fri, 6 Jan 2023 19:53:43 +0100 Subject: Better enclosures (#4944) * Better enclosures #fix https://github.com/FreshRSS/FreshRSS/issues/4702 Improvement of https://github.com/FreshRSS/FreshRSS/pull/2898 * A few fixes * Better enclosure titles * Improve thumbnails * Implement thumbnail for HTML+XPath * Avoid duplicate enclosures #fix https://github.com/FreshRSS/FreshRSS/issues/1668 * Fix regex * Add basic support for media:credit And use
for enclosures * Fix link encoding + simplify code * Fix some SimplePie bugs Encoding errors in enclosure links * Remove debugging syslog * Remove debugging syslog * SimplePie fix multiple RSS2 enclosures #fix https://github.com/FreshRSS/FreshRSS/issues/4974 * Improve thumbnails * Performance with yield Avoid generating all enclosures if not used * API keep providing enclosures inside content Clients are typically not showing the enclosures to the users (tested with News+, FeedMe, Readrops, Fluent Reader Lite) * Lint * Fix API output enclosure * Fix API content strcut * API tolerate enclosures without a type --- app/Controllers/feedController.php | 2 +- app/Models/Entry.php | 167 ++++++++++++++++++---- app/Models/Feed.php | 73 ++++------ app/views/helpers/index/normal/entry_header.phtml | 5 +- app/views/index/normal.phtml | 2 +- app/views/index/reader.phtml | 2 +- app/views/index/rss.phtml | 22 ++- lib/SimplePie/SimplePie/Enclosure.php | 2 +- lib/SimplePie/SimplePie/Item.php | 25 ++-- 9 files changed, 211 insertions(+), 89 deletions(-) (limited to 'app/Models/Feed.php') diff --git a/app/Controllers/feedController.php b/app/Controllers/feedController.php index 319faece8..3ef3af67d 100755 --- a/app/Controllers/feedController.php +++ b/app/Controllers/feedController.php @@ -949,7 +949,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { $this->view->htmlContent = $fullContent; } else { $this->view->selectorSuccess = false; - $this->view->htmlContent = $entry->content(); + $this->view->htmlContent = $entry->content(false); } } catch (Exception $e) { $this->view->fatalError = _t('feedback.sub.feed.selector_preview.http_error'); diff --git a/app/Models/Entry.php b/app/Models/Entry.php index 47fcf3b4a..ec7629253 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -67,7 +67,9 @@ class FreshRSS_Entry extends Minz_Model { $dao['content'] = ''; } if (!empty($dao['thumbnail'])) { - $dao['content'] .= '

'; + $dao['attributes']['thumbnail'] = [ + 'url' => $dao['thumbnail'], + ]; } $entry = new FreshRSS_Entry( $dao['id_feed'] ?? 0, @@ -116,15 +118,117 @@ class FreshRSS_Entry extends Minz_Model { return $this->authors; } } - public function content(): string { - return $this->content; + + /** + * Basic test without ambition to catch all cases such as unquoted addresses, variants of entities, HTML comments, etc. + */ + private static function containsLink(string $html, string $link): bool { + return preg_match('/(?P[\'"])' . preg_quote($link, '/') . '(?P=delim)/', $html) == 1; + } + + private static function enclosureIsImage(array $enclosure): bool { + $elink = $enclosure['url'] ?? ''; + $length = $enclosure['length'] ?? 0; + $medium = $enclosure['medium'] ?? ''; + $mime = $enclosure['type'] ?? ''; + + return $elink != '' && $medium === 'image' || strpos($mime, 'image') === 0 || + ($mime == '' && $length == 0 && preg_match('/[.](avif|gif|jpe?g|png|svg|webp)$/i', $elink)); } - /** @return array> */ - public function enclosures(bool $searchBodyImages = false): array { - $results = []; + /** + * @param bool $withEnclosures Set to true to include the enclosures in the returned HTML, false otherwise. + * @param bool $allowDuplicateEnclosures Set to false to remove obvious enclosure duplicates (based on simple string comparison), true otherwise. + * @return string HTML content + */ + public function content(bool $withEnclosures = true, bool $allowDuplicateEnclosures = false): string { + if (!$withEnclosures) { + return $this->content; + } + + $content = $this->content; + + $thumbnail = $this->attributes('thumbnail'); + if (!empty($thumbnail['url'])) { + $elink = $thumbnail['url']; + if ($allowDuplicateEnclosures || !self::containsLink($content, $elink)) { + $content .= << +

+ +

+
+HTML; + } + } + + $attributeEnclosures = $this->attributes('enclosures'); + if (empty($attributeEnclosures)) { + return $content; + } + + foreach ($attributeEnclosures as $enclosure) { + $elink = $enclosure['url'] ?? ''; + if ($elink == '') { + continue; + } + if (!$allowDuplicateEnclosures && self::containsLink($content, $elink)) { + continue; + } + $credit = $enclosure['credit'] ?? ''; + $description = $enclosure['description'] ?? ''; + $length = $enclosure['length'] ?? 0; + $medium = $enclosure['medium'] ?? ''; + $mime = $enclosure['type'] ?? ''; + $thumbnails = $enclosure['thumbnails'] ?? []; + $etitle = $enclosure['title'] ?? ''; + + $content .= '
'; + + foreach ($thumbnails as $thumbnail) { + $content .= '

'; + } + + if (self::enclosureIsImage($enclosure)) { + $content .= '

'; + } elseif ($medium === 'audio' || strpos($mime, 'audio') === 0) { + $content .= '

💾

'; + } elseif ($medium === 'video' || strpos($mime, 'video') === 0) { + $content .= '

💾

'; + } else { //e.g. application, text, unknown + $content .= '

💾

'; + } + + if ($credit != '') { + $content .= '

© ' . $credit . '

'; + } + if ($description != '') { + $content .= '
' . $description . '
'; + } + $content .= "
\n"; + } + + return $content; + } + + /** @return iterable> */ + public function enclosures(bool $searchBodyImages = false) { + $attributeEnclosures = $this->attributes('enclosures'); + if (is_array($attributeEnclosures)) { + // FreshRSS 1.20.1+: The enclosures are saved as attributes + yield from $attributeEnclosures; + } try { - $searchEnclosures = strpos($this->content, '

content, 'query('//div[@class="enclosure"]/p[@class="enclosure-content"]/*[@src]'); foreach ($enclosures as $enclosure) { $result = [ @@ -148,7 +253,7 @@ class FreshRSS_Entry extends Minz_Model { case 'audio': $result['medium'] = 'audio'; break; } } - $results[] = $result; + yield Minz_Helper::htmlspecialchars_utf8($result); } } if ($searchBodyImages) { @@ -159,26 +264,31 @@ class FreshRSS_Entry extends Minz_Model { $src = $img->getAttribute('data-src'); } if ($src != null) { - $results[] = [ + $result = [ 'url' => $src, - 'alt' => $img->getAttribute('alt'), ]; + yield Minz_Helper::htmlspecialchars_utf8($result); } } } - return $results; } catch (Exception $ex) { - return $results; + Minz_Log::debug(__METHOD__ . ' ' . $ex->getMessage()); } } /** * @return array|null */ - public function thumbnail() { - foreach ($this->enclosures(true) as $enclosure) { - if (!empty($enclosure['url']) && empty($enclosure['type'])) { - return $enclosure; + public function thumbnail(bool $searchEnclosures = true) { + $thumbnail = $this->attributes('thumbnail'); + if (!empty($thumbnail['url'])) { + return $thumbnail; + } + if ($searchEnclosures) { + foreach ($this->enclosures(true) as $enclosure) { + if (self::enclosureIsImage($enclosure)) { + return $enclosure; + } } } return null; @@ -587,7 +697,7 @@ class FreshRSS_Entry extends Minz_Model { if ($entry) { // l’article existe déjà en BDD, en se contente de recharger ce contenu - $this->content = $entry->content(); + $this->content = $entry->content(false); } else { try { // The article is not yet in the database, so let’s fetch it @@ -629,7 +739,7 @@ class FreshRSS_Entry extends Minz_Model { 'guid' => $this->guid(), 'title' => $this->title(), 'author' => $this->authors(true), - 'content' => $this->content(), + 'content' => $this->content(false), 'link' => $this->link(), 'date' => $this->date(true), 'hash' => $this->hash(), @@ -677,7 +787,6 @@ class FreshRSS_Entry extends Minz_Model { 'published' => $this->date(true), // 'updated' => $this->date(true), 'title' => $this->title(), - 'summary' => ['content' => $this->content()], 'canonical' => [ ['href' => htmlspecialchars_decode($this->link(), ENT_QUOTES)], ], @@ -697,13 +806,16 @@ class FreshRSS_Entry extends Minz_Model { if ($mode === 'compat') { $item['title'] = escapeToUnicodeAlternative($this->title(), false); unset($item['alternate'][0]['type']); - if (mb_strlen($this->content(), 'UTF-8') > self::API_MAX_COMPAT_CONTENT_LENGTH) { - $item['summary']['content'] = mb_strcut($this->content(), 0, self::API_MAX_COMPAT_CONTENT_LENGTH, 'UTF-8'); - } - } elseif ($mode === 'freshrss') { + $item['summary'] = [ + 'content' => mb_strcut($this->content(true), 0, self::API_MAX_COMPAT_CONTENT_LENGTH, 'UTF-8'), + ]; + } else { + $item['content'] = [ + 'content' => $this->content(false), + ]; + } + if ($mode === 'freshrss') { $item['guid'] = $this->guid(); - unset($item['summary']); - $item['content'] = ['content' => $this->content()]; } if ($category != null && $mode !== 'freshrss') { $item['categories'][] = 'user/-/label/' . htmlspecialchars_decode($category->name(), ENT_QUOTES); @@ -718,10 +830,11 @@ class FreshRSS_Entry extends Minz_Model { } } foreach ($this->enclosures() as $enclosure) { - if (!empty($enclosure['url']) && !empty($enclosure['type'])) { + if (!empty($enclosure['url'])) { $media = [ 'href' => $enclosure['url'], - 'type' => $enclosure['type'], + 'type' => $enclosure['type'] ?? $enclosure['medium'] ?? + (self::enclosureIsImage($enclosure) ? 'image' : ''), ]; if (!empty($enclosure['length'])) { $media['length'] = intval($enclosure['length']); diff --git a/app/Models/Feed.php b/app/Models/Feed.php index 538814370..a63c2b3ea 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -502,61 +502,46 @@ class FreshRSS_Feed extends Minz_Model { $content = html_only_entity_decode($item->get_content()); - if ($item->get_enclosures() != null) { - $elinks = array(); + $attributeThumbnail = $item->get_thumbnail() ?? []; + if (empty($attributeThumbnail['url'])) { + $attributeThumbnail['url'] = ''; + } + + $attributeEnclosures = []; + if (!empty($item->get_enclosures())) { foreach ($item->get_enclosures() as $enclosure) { $elink = $enclosure->get_link(); - if ($elink != '' && empty($elinks[$elink])) { - $content .= '

'; - - if ($enclosure->get_title() != '') { - $content .= '

' . $enclosure->get_title() . '

'; - } - - $enclosureContent = ''; - $elinks[$elink] = true; + if ($elink != '') { + $etitle = $enclosure->get_title() ?? ''; + $credit = $enclosure->get_credit() ?? null; + $description = $enclosure->get_description() ?? ''; $mime = strtolower($enclosure->get_type() ?? ''); $medium = strtolower($enclosure->get_medium() ?? ''); $height = $enclosure->get_height(); $width = $enclosure->get_width(); $length = $enclosure->get_length(); - if ($medium === 'image' || strpos($mime, 'image') === 0 || - ($mime == '' && $length == null && ($width != 0 || $height != 0 || preg_match('/[.](avif|gif|jpe?g|png|svg|webp)$/i', $elink)))) { - $enclosureContent .= '

'; - } elseif ($medium === 'audio' || strpos($mime, 'audio') === 0) { - $enclosureContent .= '

💾

'; - } elseif ($medium === 'video' || strpos($mime, 'video') === 0) { - $enclosureContent .= '

💾

'; - } else { //e.g. application, text, unknown - $enclosureContent .= '

💾

'; - } - $thumbnailContent = ''; - if ($enclosure->get_thumbnails() != null) { + $attributeEnclosure = [ + 'url' => $elink, + ]; + if ($etitle != '') $attributeEnclosure['title'] = $etitle; + if ($credit != null) $attributeEnclosure['credit'] = $credit->get_name(); + if ($description != '') $attributeEnclosure['description'] = $description; + if ($mime != '') $attributeEnclosure['type'] = $mime; + if ($medium != '') $attributeEnclosure['medium'] = $medium; + if ($length != '') $attributeEnclosure['length'] = intval($length); + if ($height != '') $attributeEnclosure['height'] = intval($height); + if ($width != '') $attributeEnclosure['width'] = intval($width); + + if (!empty($enclosure->get_thumbnails())) { foreach ($enclosure->get_thumbnails() as $thumbnail) { - if (empty($elinks[$thumbnail])) { - $elinks[$thumbnail] = true; - $thumbnailContent .= '

'; + if ($thumbnail !== $attributeThumbnail['url']) { + $attributeEnclosure['thumbnails'][] = $thumbnail; } } } - $content .= $thumbnailContent; - $content .= $enclosureContent; - - if ($enclosure->get_description() != '') { - $content .= '

' . $enclosure->get_description() . '

'; - } - $content .= "
\n"; + $attributeEnclosures[] = $attributeEnclosure; } } } @@ -586,6 +571,10 @@ class FreshRSS_Feed extends Minz_Model { ); $entry->_tags($tags); $entry->_feed($this); + if (!empty($attributeThumbnail['url'])) { + $entry->_attributes('thumbnail', $attributeThumbnail); + } + $entry->_attributes('enclosures', $attributeEnclosures); $entry->hash(); //Must be computed before loading full content $entry->loadCompleteContent(); // Optionally load full content for truncated feeds diff --git a/app/views/helpers/index/normal/entry_header.phtml b/app/views/helpers/index/normal/entry_header.phtml index 43eeb7f8a..92eacf617 100644 --- a/app/views/helpers/index/normal/entry_header.phtml +++ b/app/views/helpers/index/normal/entry_header.phtml @@ -42,8 +42,7 @@ ?>
  • entry->thumbnail(); if ($thumbnail != null): - ?> /> alt="" />
  • @@ -62,7 +61,7 @@ ?>
    entry->content()), 0, 500, 'UTF-8')) ?>
    entry->content(false)), 0, 500, 'UTF-8')) ?>
  •  
  • diff --git a/app/views/index/normal.phtml b/app/views/index/normal.phtml index 6f7c47677..847c307ab 100644 --- a/app/views/index/normal.phtml +++ b/app/views/index/normal.phtml @@ -162,7 +162,7 @@ $today = @strtotime('today');
    entry->content()) : $this->entry->content(); + echo $lazyload && $hidePosts ? lazyimg($this->entry->content(true)) : $this->entry->content(true); ?>
    show_author_date === 'f' || FreshRSS_Context::$user_conf->show_author_date === 'b'; diff --git a/app/views/index/reader.phtml b/app/views/index/reader.phtml index 5789f229b..a2ea0e989 100644 --- a/app/views/index/reader.phtml +++ b/app/views/index/reader.phtml @@ -136,7 +136,7 @@ $MAX_TAGS_DISPLAYED = FreshRSS_Context::$user_conf->show_tags_max;
    - content() ?> + content(true) ?>
    show_author_date === 'f' || FreshRSS_Context::$user_conf->show_author_date === 'b'; diff --git a/app/views/index/rss.phtml b/app/views/index/rss.phtml index 0b07a02f3..0b3dc7955 100755 --- a/app/views/index/rss.phtml +++ b/app/views/index/rss.phtml @@ -29,29 +29,41 @@ foreach ($this->entries as $item) { $authors = $item->authors(); if (is_array($authors)) { foreach ($authors as $author) { - echo "\t\t\t" , '', $author, '', "\n"; + echo "\t\t\t", '', $author, '', "\n"; } } $categories = $item->tags(); if (is_array($categories)) { foreach ($categories as $category) { - echo "\t\t\t" , '', $category, '', "\n"; + echo "\t\t\t", '', $category, '', "\n"; } } + $thumbnail = $item->thumbnail(false); + if (!empty($thumbnail['url'])) { + // https://www.rssboard.org/media-rss#media-thumbnails + echo "\t\t\t", '', "\n"; + } $enclosures = $item->enclosures(false); if (is_array($enclosures)) { foreach ($enclosures as $enclosure) { // https://www.rssboard.org/media-rss - echo "\t\t\t" , '', "\n"; + . '">' + . (empty($enclosure['title']) ? '' : '' . $enclosure['title'] . '') + . (empty($enclosure['credit']) ? '' : '' . $enclosure['credit'] . '') + . '', "\n"; } } ?> content(); + echo $item->content(false); ?>]]> date(true)) ?> id() > 0 ? $item->id() : $item->guid() ?> diff --git a/lib/SimplePie/SimplePie/Enclosure.php b/lib/SimplePie/SimplePie/Enclosure.php index cc0d038b5..04bade09f 100644 --- a/lib/SimplePie/SimplePie/Enclosure.php +++ b/lib/SimplePie/SimplePie/Enclosure.php @@ -627,7 +627,7 @@ class SimplePie_Enclosure { if ($this->link !== null) { - return urldecode($this->link); + return $this->link; } return null; diff --git a/lib/SimplePie/SimplePie/Item.php b/lib/SimplePie/SimplePie/Item.php index 2fb1d3284..1285fd536 100644 --- a/lib/SimplePie/SimplePie/Item.php +++ b/lib/SimplePie/SimplePie/Item.php @@ -427,7 +427,16 @@ class SimplePie_Item { if ($return = $this->get_item_tags(SIMPLEPIE_NAMESPACE_MEDIARSS, 'thumbnail')) { - $this->data['thumbnail'] = $return[0]['attribs']['']; + $thumbnail = $return[0]['attribs']['']; + if (empty($thumbnail['url'])) + { + $this->data['thumbnail'] = null; + } + else + { + $thumbnail['url'] = $this->sanitize($thumbnail['url'], SIMPLEPIE_CONSTRUCT_IRI, $this->get_base($return[0])); + $this->data['thumbnail'] = $thumbnail; + } } else { @@ -2847,9 +2856,9 @@ class SimplePie_Item } } - if ($enclosure = $this->get_item_tags(SIMPLEPIE_NAMESPACE_RSS_20, 'enclosure')) + foreach ($this->get_item_tags(SIMPLEPIE_NAMESPACE_RSS_20, 'enclosure') ?? [] as $enclosure) { - if (isset($enclosure[0]['attribs']['']['url'])) + if (isset($enclosure['attribs']['']['url'])) { // Attributes $bitrate = null; @@ -2867,15 +2876,15 @@ class SimplePie_Item $url = null; $width = null; - $url = $this->sanitize($enclosure[0]['attribs']['']['url'], SIMPLEPIE_CONSTRUCT_IRI, $this->get_base($enclosure[0])); + $url = $this->sanitize($enclosure['attribs']['']['url'], SIMPLEPIE_CONSTRUCT_IRI, $this->get_base($enclosure)); $url = $this->feed->sanitize->https_url($url); - if (isset($enclosure[0]['attribs']['']['type'])) + if (isset($enclosure['attribs']['']['type'])) { - $type = $this->sanitize($enclosure[0]['attribs']['']['type'], SIMPLEPIE_CONSTRUCT_TEXT); + $type = $this->sanitize($enclosure['attribs']['']['type'], SIMPLEPIE_CONSTRUCT_TEXT); } - if (isset($enclosure[0]['attribs']['']['length'])) + if (isset($enclosure['attribs']['']['length'])) { - $length = intval($enclosure[0]['attribs']['']['length']); + $length = intval($enclosure['attribs']['']['length']); } // Since we don't have group or content for these, we'll just pass the '*_parent' variables directly to the constructor -- cgit v1.2.3 From 07efaf71eac19934d858df678576823da131d1bb Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Thu, 26 Jan 2023 08:59:34 +0100 Subject: Fix error handling when updating URL (#5039) Fix 3 related error handling when updating the feed URL with an invalid URL. Previously leading to unclear 500 page with additional PHP errors. --- app/Controllers/subscriptionController.php | 5 ++++- app/Models/Feed.php | 7 ++++--- lib/lib_rss.php | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) (limited to 'app/Models/Feed.php') diff --git a/app/Controllers/subscriptionController.php b/app/Controllers/subscriptionController.php index 315187aaa..c1acfd958 100644 --- a/app/Controllers/subscriptionController.php +++ b/app/Controllers/subscriptionController.php @@ -256,7 +256,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController { $url_redirect = array('c' => 'subscription', 'params' => array('id' => $id)); } - if ($feedDAO->updateFeed($id, $values) !== false) { + if ($values['url'] != '' && $feedDAO->updateFeed($id, $values) !== false) { $feed->_categoryId($values['category']); // update url and website values for faviconPrepare $feed->_url($values['url'], false); @@ -265,6 +265,9 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController { Minz_Request::good(_t('feedback.sub.feed.updated'), $url_redirect); } else { + if ($values['url'] == '') { + Minz_Log::warning('Invalid feed URL!'); + } Minz_Request::bad(_t('feedback.sub.feed.error'), $url_redirect); } } diff --git a/app/Models/Feed.php b/app/Models/Feed.php index a63c2b3ea..09cacbd61 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -259,13 +259,14 @@ class FreshRSS_Feed extends Minz_Model { } public function _url(string $value, bool $validate = true) { $this->hash = ''; + $url = $value; if ($validate) { - $value = checkUrl($value); + $url = checkUrl($url); } - if ($value == '') { + if ($url == '') { throw new FreshRSS_BadUrl_Exception($value); } - $this->url = $value; + $this->url = $url; } public function _kind(int $value) { $this->kind = $value; diff --git a/lib/lib_rss.php b/lib/lib_rss.php index e5362bc5c..d1821b639 100644 --- a/lib/lib_rss.php +++ b/lib/lib_rss.php @@ -845,7 +845,7 @@ function errorMessageInfo($errorTitle, $error = '') { $details = ''; // Prevent empty tags by checking if error isn not empty first if ($error) { - $error = htmlspecialchars($error, ENT_NOQUOTES, 'UTF-8'); + $error = htmlspecialchars($error, ENT_NOQUOTES, 'UTF-8') . "\n"; // First line is the main message, other lines are the details list($message, $details) = explode("\n", $error, 2); -- cgit v1.2.3 From e899e4edd97c296a29b2a8da2c2e3b598622c36e Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Mon, 6 Feb 2023 15:42:53 +0100 Subject: More robust application of access permissions (#5062) * More robust application of access permissions We were in particular missing directory traversal `+X` in our current recommendations. Extracted to own shell script so it can easily be invoked. Update access permissions in Docker to account to be more robust. #fix https://github.com/FreshRSS/FreshRSS/discussions/5037 * Minor simplification * Restrict mkdir permissions Default mkdir permissions are 0777, which is not good for security, so downgrade to 0770. --- Docker/entrypoint.sh | 11 +++++------ README.fr.md | 4 ++-- app/Controllers/userController.php | 2 +- app/Models/Feed.php | 4 ++-- cli/README.md | 4 +--- cli/_cli.php | 2 +- cli/access-permissions.sh | 19 +++++++++++++++++++ cli/i18n/I18nFile.php | 2 +- docs/en/admins/06_LinuxInstall.md | 9 +-------- docs/en/admins/07_LinuxUpdate.md | 4 ++-- lib/Minz/Migrator.php | 2 +- 11 files changed, 36 insertions(+), 27 deletions(-) create mode 100755 cli/access-permissions.sh (limited to 'app/Models/Feed.php') diff --git a/Docker/entrypoint.sh b/Docker/entrypoint.sh index 018946397..cbc2443d6 100755 --- a/Docker/entrypoint.sh +++ b/Docker/entrypoint.sh @@ -7,8 +7,6 @@ find /etc/php*/ -type f -name php.ini -exec sed -r -i "\\#^;?date.timezone#s#^.* find /etc/php*/ -type f -name php.ini -exec sed -r -i "\\#^;?post_max_size#s#^.*#post_max_size = 32M#" {} \; find /etc/php*/ -type f -name php.ini -exec sed -r -i "\\#^;?upload_max_filesize#s#^.*#upload_max_filesize = 32M#" {} \; -php -f ./cli/prepare.php >/dev/null - if [ -n "$LISTEN" ]; then find /etc/apache2/ -type f -name FreshRSS.Apache.conf -exec sed -r -i "\\#^Listen#s#^.*#Listen $LISTEN#" {} \; fi @@ -24,6 +22,10 @@ if [ -n "$CRON_MIN" ]; then -r "s#^[^ ]+ #$CRON_MIN #" | crontab - fi +./cli/access-permissions.sh + +php -f ./cli/prepare.php >/dev/null + if [ -n "$FRESHRSS_INSTALL" ]; then # shellcheck disable=SC2046 php -f ./cli/do-install.php -- \ @@ -57,9 +59,6 @@ if [ -n "$FRESHRSS_USER" ]; then fi fi -chown -R :www-data . -chmod -R g+r . -chmod -R g+w ./data/ -chmod g+x ./extensions/ +./cli/access-permissions.sh exec "$@" diff --git a/README.fr.md b/README.fr.md index 99b5a1a2c..221385ab5 100644 --- a/README.fr.md +++ b/README.fr.md @@ -113,7 +113,7 @@ cd FreshRSS sudo git checkout $(git describe --tags --abbrev=0) # Mettre les droits d’accès pour le serveur Web -sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/ +sudo cli/access-permissions.sh # Si vous souhaitez permettre les mises à jour par l’interface Web sudo chmod -R g+w . @@ -126,7 +126,7 @@ sudo ln -s /usr/share/FreshRSS/p /var/www/html/FreshRSS # Mettre à jour FreshRSS vers une nouvelle version par git cd /usr/share/FreshRSS sudo git pull -sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/ +sudo cli/access-permissions.sh ``` Voir la [documentation de la ligne de commande](cli/README.md) pour plus de détails. diff --git a/app/Controllers/userController.php b/app/Controllers/userController.php index 55b4ca7cb..ac8f3be82 100644 --- a/app/Controllers/userController.php +++ b/app/Controllers/userController.php @@ -242,7 +242,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { } if ($ok) { if (!is_dir($homeDir)) { - mkdir($homeDir); + mkdir($homeDir, 0770, true); } $ok &= (file_put_contents($configPath, "salt); $hubJson = array( 'hub' => $this->hubUrl, 'key' => $key, ); file_put_contents($hubFilename, json_encode($hubJson)); - @mkdir(PSHB_PATH . '/keys/'); + @mkdir(PSHB_PATH . '/keys/', 0770, true); file_put_contents(PSHB_PATH . '/keys/' . $key . '.txt', $this->selfUrl); $text = 'WebSub prepared for ' . $this->url; Minz_Log::debug($text); diff --git a/cli/README.md b/cli/README.md index e290cc267..cb43b7340 100644 --- a/cli/README.md +++ b/cli/README.md @@ -18,9 +18,7 @@ In any case, when you are done with a series of commands, you should re-apply th ```sh cd /usr/share/FreshRSS -sudo chown -R :www-data . -sudo chmod -R g+r . -sudo chmod -R g+w ./data/ +sudo cli/access-permissions.sh ``` diff --git a/cli/_cli.php b/cli/_cli.php index 10a92385a..0d2c8695f 100644 --- a/cli/_cli.php +++ b/cli/_cli.php @@ -44,7 +44,7 @@ function cliInitUser($username) { function accessRights() { echo 'ℹ️ Remember to re-apply the appropriate access rights, such as:', - "\t", 'sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/', "\n"; + "\t", 'sudo cli/access-permissions.sh', "\n"; } function done($ok = true) { diff --git a/cli/access-permissions.sh b/cli/access-permissions.sh new file mode 100755 index 000000000..c13130a4b --- /dev/null +++ b/cli/access-permissions.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Apply access permissions + +if [ ! -f './constants.php' ] || [ ! -d './cli/' ]; then + echo >&2 '⛔ It does not look like a FreshRSS directory; exiting!' + exit 2 +fi + +if [ "$(id -u)" -ne 0 ]; then + echo >&2 '⛔ Applying access permissions require running as root or sudo!' + exit 3 +fi + +# Based on group access +chown -R :www-data . +# Read files, and directory traversal +chmod -R g+rX . +# Write access +chmod -R g+w ./data/ diff --git a/cli/i18n/I18nFile.php b/cli/i18n/I18nFile.php index fca31d662..12a04c6a2 100644 --- a/cli/i18n/I18nFile.php +++ b/cli/i18n/I18nFile.php @@ -27,7 +27,7 @@ class I18nFile { foreach ($i18n as $language => $file) { $dir = I18N_PATH . DIRECTORY_SEPARATOR . $language; if (!file_exists($dir)) { - mkdir($dir); + mkdir($dir, 0770, true); } foreach ($file as $name => $content) { $filename = $dir . DIRECTORY_SEPARATOR . $name; diff --git a/docs/en/admins/06_LinuxInstall.md b/docs/en/admins/06_LinuxInstall.md index e92fc3247..1af041efe 100644 --- a/docs/en/admins/06_LinuxInstall.md +++ b/docs/en/admins/06_LinuxInstall.md @@ -81,14 +81,7 @@ Change to the new FreshRSS directory, and set the permissions so that your Web s ```sh cd FreshRSS -chown -R :www-data . -sudo chmod -R g+r . -``` - -We’ll also need to allow the data folder to be written to, like so: - -```sh -chmod -R g+w ./data/ +sudo cli/access-permissions.sh ``` Optional: If you would like to allow updates from the Web interface, set write permissions diff --git a/docs/en/admins/07_LinuxUpdate.md b/docs/en/admins/07_LinuxUpdate.md index 834dfaaef..27e8ef451 100644 --- a/docs/en/admins/07_LinuxUpdate.md +++ b/docs/en/admins/07_LinuxUpdate.md @@ -64,7 +64,7 @@ If your local user doesn’t have write access to the FreshRSS folder, use a sud 6. Re-set correct permissions so that your web server can access the files ```sh - chown -R :www-data . && chmod -R g+r . && chmod -R g+w ./data/ + cli/access-permissions.sh ``` ## Using the Zip archive @@ -91,7 +91,7 @@ If your local user doesn’t have write access to the FreshRSS folder, use a sud 5. Re-set permissions ```sh - chown -R :www-data . && chmod -R g+r . && chmod -R g+w ./data/ + cli/access-permissions.sh ``` 6. Clean up the FreshRSS directory by deleting the downloaded zip and the temporary directory diff --git a/lib/Minz/Migrator.php b/lib/Minz/Migrator.php index 0f28237c5..ef89a3b55 100644 --- a/lib/Minz/Migrator.php +++ b/lib/Minz/Migrator.php @@ -55,7 +55,7 @@ class Minz_Migrator } $lock_path = $applied_migrations_path . '.lock'; - if (!@mkdir($lock_path)) { + if (!@mkdir($lock_path, 0770, true)) { // Someone is probably already executing the migrations (the folder // already exists). // We should probably return something else, but we don't want the -- cgit v1.2.3 From 05ae1b0d2684cea4eda664c5ea1a995cb9f0c4b9 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Thu, 9 Feb 2023 13:57:20 +0100 Subject: XML+XPath (#5076) * XML+XPath #fix https://github.com/FreshRSS/FreshRSS/issues/5075 Implementation allowing to take an XML document as input using an XML parser (instead of an HTML parser for HTML+XPath) * Remove noise from another PR * Better MIME for XML * And add glob *.xml for cache cleaning * Minor syntax * Add glob json for clean cache --- app/Controllers/feedController.php | 14 ++++++++++---- app/Controllers/subscriptionController.php | 2 +- app/Models/Feed.php | 29 ++++++++++++++++++++++++----- app/Services/ExportService.php | 1 + app/Services/ImportService.php | 5 ++++- app/i18n/cz/sub.php | 1 + app/i18n/de/sub.php | 1 + app/i18n/el/sub.php | 1 + app/i18n/en-us/sub.php | 1 + app/i18n/en/sub.php | 1 + app/i18n/es/sub.php | 1 + app/i18n/fr/sub.php | 1 + app/i18n/he/sub.php | 1 + app/i18n/id/sub.php | 1 + app/i18n/it/sub.php | 1 + app/i18n/ja/sub.php | 1 + app/i18n/ko/sub.php | 1 + app/i18n/nl/sub.php | 1 + app/i18n/oc/sub.php | 1 + app/i18n/pl/sub.php | 1 + app/i18n/pt-br/sub.php | 1 + app/i18n/ru/sub.php | 1 + app/i18n/sk/sub.php | 1 + app/i18n/tr/sub.php | 1 + app/i18n/zh-cn/sub.php | 1 + app/i18n/zh-tw/sub.php | 1 + app/views/helpers/export/opml.phtml | 11 +++++++++-- app/views/helpers/feed/update.phtml | 5 +++-- app/views/subscription/add.phtml | 1 + docs/en/developers/OPML.md | 4 +++- lib/lib_rss.php | 14 ++++++++++++-- p/scripts/feed.js | 11 +++++++++-- 32 files changed, 98 insertions(+), 20 deletions(-) (limited to 'app/Models/Feed.php') diff --git a/app/Controllers/feedController.php b/app/Controllers/feedController.php index 2bef85f0e..84f38fe5e 100644 --- a/app/Controllers/feedController.php +++ b/app/Controllers/feedController.php @@ -81,6 +81,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { $feed->load(true); //Throws FreshRSS_Feed_Exception, Minz_FileNotExistException break; case FreshRSS_Feed::KIND_HTML_XPATH: + case FreshRSS_Feed::KIND_XML_XPATH: $feed->_website($url); break; } @@ -201,8 +202,8 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { $timeout = intval(Minz_Request::param('timeout', 0)); $attributes['timeout'] = $timeout > 0 ? $timeout : null; - $feed_kind = Minz_Request::param('feed_kind', FreshRSS_Feed::KIND_RSS); - if ($feed_kind == FreshRSS_Feed::KIND_HTML_XPATH) { + $feed_kind = (int)Minz_Request::param('feed_kind', FreshRSS_Feed::KIND_RSS); + if ($feed_kind === FreshRSS_Feed::KIND_HTML_XPATH || $feed_kind === FreshRSS_Feed::KIND_XML_XPATH) { $xPathSettings = []; if (Minz_Request::param('xPathFeedTitle', '') != '') $xPathSettings['feedTitle'] = Minz_Request::param('xPathFeedTitle', '', true); if (Minz_Request::param('xPathItem', '') != '') $xPathSettings['item'] = Minz_Request::param('xPathItem', '', true); @@ -385,10 +386,15 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { if ($simplePiePush) { $simplePie = $simplePiePush; //Used by WebSub } elseif ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH) { - $simplePie = $feed->loadHtmlXpath(false, $isNewFeed); - if ($simplePie == null) { + $simplePie = $feed->loadHtmlXpath(); + if ($simplePie === null) { throw new FreshRSS_Feed_Exception('HTML+XPath Web scraping failed for [' . $feed->url(false) . ']'); } + } elseif ($feed->kind() === FreshRSS_Feed::KIND_XML_XPATH) { + $simplePie = $feed->loadHtmlXpath(); + if ($simplePie === null) { + throw new FreshRSS_Feed_Exception('XML+XPath parsing failed for [' . $feed->url(false) . ']'); + } } else { $simplePie = $feed->load(false, $isNewFeed); } diff --git a/app/Controllers/subscriptionController.php b/app/Controllers/subscriptionController.php index b2ee046d9..f0355a82a 100644 --- a/app/Controllers/subscriptionController.php +++ b/app/Controllers/subscriptionController.php @@ -203,7 +203,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController { $feed->_filtersAction('read', preg_split('/[\n\r]+/', Minz_Request::param('filteractions_read', ''))); $feed->_kind(intval(Minz_Request::param('feed_kind', FreshRSS_Feed::KIND_RSS))); - if ($feed->kind() == FreshRSS_Feed::KIND_HTML_XPATH) { + if ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH || $feed->kind() === FreshRSS_Feed::KIND_XML_XPATH) { $xPathSettings = []; if (Minz_Request::param('xPathItem', '') != '') $xPathSettings['item'] = Minz_Request::param('xPathItem', '', true); if (Minz_Request::param('xPathItemTitle', '') != '') $xPathSettings['itemTitle'] = Minz_Request::param('xPathItemTitle', '', true); diff --git a/app/Models/Feed.php b/app/Models/Feed.php index f7ff76768..7c46199a5 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -17,6 +17,11 @@ class FreshRSS_Feed extends Minz_Model { * @var int */ const KIND_HTML_XPATH = 10; + /** + * Normal XML with XPath scraping + * @var int + */ + const KIND_XML_XPATH = 15; /** * Normal JSON with XPath scraping * @var int @@ -586,7 +591,7 @@ class FreshRSS_Feed extends Minz_Model { /** * @return SimplePie|null */ - public function loadHtmlXpath(bool $loadDetails = false, bool $noCache = false) { + public function loadHtmlXpath() { if ($this->url == '') { return null; } @@ -614,8 +619,9 @@ class FreshRSS_Feed extends Minz_Model { return null; } - $cachePath = FreshRSS_Feed::cacheFilename($feedSourceUrl, $this->attributes(), FreshRSS_Feed::KIND_HTML_XPATH); - $html = httpGet($feedSourceUrl, $cachePath, 'html', $this->attributes()); + $cachePath = FreshRSS_Feed::cacheFilename($feedSourceUrl, $this->attributes(), $this->kind()); + $html = httpGet($feedSourceUrl, $cachePath, + $this->kind() === FreshRSS_Feed::KIND_XML_XPATH ? 'xml' : 'html', $this->attributes()); if (strlen($html) <= 0) { return null; } @@ -630,7 +636,18 @@ class FreshRSS_Feed extends Minz_Model { $doc = new DOMDocument(); $doc->recover = true; $doc->strictErrorChecking = false; - $doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + + switch ($this->kind()) { + case FreshRSS_Feed::KIND_HTML_XPATH: + $doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + break; + case FreshRSS_Feed::KIND_XML_XPATH: + $doc->loadXML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + break; + default: + return null; + } + $xpath = new DOMXPath($doc); $view->rss_title = $xPathFeedTitle == '' ? $this->name() : htmlspecialchars(@$xpath->evaluate('normalize-space(' . $xPathFeedTitle . ')'), ENT_COMPAT, 'UTF-8'); @@ -776,8 +793,10 @@ class FreshRSS_Feed extends Minz_Model { public static function cacheFilename(string $url, array $attributes, int $kind = FreshRSS_Feed::KIND_RSS): string { $simplePie = customSimplePie($attributes); $filename = $simplePie->get_cache_filename($url); - if ($kind == FreshRSS_Feed::KIND_HTML_XPATH) { + if ($kind === FreshRSS_Feed::KIND_HTML_XPATH) { return CACHE_PATH . '/' . $filename . '.html'; + } elseif ($kind === FreshRSS_Feed::KIND_XML_XPATH) { + return CACHE_PATH . '/' . $filename . '.xml'; } else { return CACHE_PATH . '/' . $filename . '.spc'; } diff --git a/app/Services/ExportService.php b/app/Services/ExportService.php index 2f35666a8..6b0a3f178 100644 --- a/app/Services/ExportService.php +++ b/app/Services/ExportService.php @@ -21,6 +21,7 @@ class FreshRSS_Export_Service { const FRSS_NAMESPACE = 'https://freshrss.org/opml'; const TYPE_HTML_XPATH = 'HTML+XPath'; + const TYPE_XML_XPATH = 'XML+XPath'; const TYPE_RSS_ATOM = 'rss'; /** diff --git a/app/Services/ImportService.php b/app/Services/ImportService.php index 68aa6f741..55aa28679 100644 --- a/app/Services/ImportService.php +++ b/app/Services/ImportService.php @@ -160,10 +160,13 @@ class FreshRSS_Import_Service { $feed->_website($website); $feed->_description($description); - switch ($feed_elt['type'] ?? '') { + switch (strtolower($feed_elt['type'] ?? '')) { case strtolower(FreshRSS_Export_Service::TYPE_HTML_XPATH): $feed->_kind(FreshRSS_Feed::KIND_HTML_XPATH); break; + case strtolower(FreshRSS_Export_Service::TYPE_XML_XPATH): + $feed->_kind(FreshRSS_Feed::KIND_XML_XPATH); + break; case strtolower(FreshRSS_Export_Service::TYPE_RSS_ATOM): default: $feed->_kind(FreshRSS_Feed::KIND_RSS); diff --git a/app/i18n/cz/sub.php b/app/i18n/cz/sub.php index a11a9359d..3d08c315b 100644 --- a/app/i18n/cz/sub.php +++ b/app/i18n/cz/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath pro:', ), 'rss' => 'RSS / Atom (výchozí)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Vymazat mezipaměť', diff --git a/app/i18n/de/sub.php b/app/i18n/de/sub.php index 580f7d348..b265c1b98 100644 --- a/app/i18n/de/sub.php +++ b/app/i18n/de/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath für:', ), 'rss' => 'RSS / Atom (Standard)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Zwischenspeicher leeren', diff --git a/app/i18n/el/sub.php b/app/i18n/el/sub.php index 424fafc7b..aae9ae412 100644 --- a/app/i18n/el/sub.php +++ b/app/i18n/el/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath for:', // TODO ), 'rss' => 'RSS / Atom (default)', // TODO + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Clear cache', // TODO diff --git a/app/i18n/en-us/sub.php b/app/i18n/en-us/sub.php index a6b311084..92d75b81e 100644 --- a/app/i18n/en-us/sub.php +++ b/app/i18n/en-us/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath for:', // IGNORE ), 'rss' => 'RSS / Atom (default)', // IGNORE + 'xml_xpath' => 'XML + XPath', // IGNORE ), 'maintenance' => array( 'clear_cache' => 'Clear cache', // IGNORE diff --git a/app/i18n/en/sub.php b/app/i18n/en/sub.php index c7e100c25..04caaff05 100644 --- a/app/i18n/en/sub.php +++ b/app/i18n/en/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath for:', ), 'rss' => 'RSS / Atom (default)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Clear cache', diff --git a/app/i18n/es/sub.php b/app/i18n/es/sub.php index 52d681067..4fd2fa393 100644 --- a/app/i18n/es/sub.php +++ b/app/i18n/es/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath para:', ), 'rss' => 'RSS / Atom (por defecto)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Borrar caché', diff --git a/app/i18n/fr/sub.php b/app/i18n/fr/sub.php index f9df0dbcc..be6dc094d 100644 --- a/app/i18n/fr/sub.php +++ b/app/i18n/fr/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath pour :', ), 'rss' => 'RSS / Atom (par défaut)', + 'xml_xpath' => 'XML + XPath', // IGNORE ), 'maintenance' => array( 'clear_cache' => 'Vider le cache', diff --git a/app/i18n/he/sub.php b/app/i18n/he/sub.php index 25552ffa1..bae5f5177 100644 --- a/app/i18n/he/sub.php +++ b/app/i18n/he/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath for:', // TODO ), 'rss' => 'RSS / Atom (default)', // TODO + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Clear cache', // TODO diff --git a/app/i18n/id/sub.php b/app/i18n/id/sub.php index 7fdf5c024..3f9a4916a 100644 --- a/app/i18n/id/sub.php +++ b/app/i18n/id/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath for:', // TODO ), 'rss' => 'RSS / Atom (default)', // TODO + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Clear cache', // TODO diff --git a/app/i18n/it/sub.php b/app/i18n/it/sub.php index 8614caca7..7ab83cf07 100644 --- a/app/i18n/it/sub.php +++ b/app/i18n/it/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath per:', ), 'rss' => 'RSS / Atom (predefinito)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Svuota cache', diff --git a/app/i18n/ja/sub.php b/app/i18n/ja/sub.php index 80548c025..2425b21f3 100644 --- a/app/i18n/ja/sub.php +++ b/app/i18n/ja/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPathは:', ), 'rss' => 'RSS / Atom (標準)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'キャッシュのクリア', diff --git a/app/i18n/ko/sub.php b/app/i18n/ko/sub.php index e0ef5990b..f376247d5 100644 --- a/app/i18n/ko/sub.php +++ b/app/i18n/ko/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => '다음의 XPath:', ), 'rss' => 'RSS / Atom (기본값)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => '캐쉬 지우기', diff --git a/app/i18n/nl/sub.php b/app/i18n/nl/sub.php index 0fa767171..631da9477 100644 --- a/app/i18n/nl/sub.php +++ b/app/i18n/nl/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath voor:', ), 'rss' => 'RSS / Atom (standaard)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Cache leegmaken', diff --git a/app/i18n/oc/sub.php b/app/i18n/oc/sub.php index 92a73057c..008b4964d 100644 --- a/app/i18n/oc/sub.php +++ b/app/i18n/oc/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath per :', ), 'rss' => 'RSS / Atom (defaut)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Escafar lo cache', diff --git a/app/i18n/pl/sub.php b/app/i18n/pl/sub.php index b6121fcb7..565401982 100644 --- a/app/i18n/pl/sub.php +++ b/app/i18n/pl/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath dla:', ), 'rss' => 'RSS / Atom (domyślne)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Wyczyść pamięć podręczną', diff --git a/app/i18n/pt-br/sub.php b/app/i18n/pt-br/sub.php index c9755755e..4cdee8681 100644 --- a/app/i18n/pt-br/sub.php +++ b/app/i18n/pt-br/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath para:', ), 'rss' => 'RSS / Atom (padrão)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Limpar o cache', diff --git a/app/i18n/ru/sub.php b/app/i18n/ru/sub.php index 5704b53b1..d13c4c4f0 100644 --- a/app/i18n/ru/sub.php +++ b/app/i18n/ru/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath для:', ), 'rss' => 'RSS / Atom (по умолчанию)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Очистить кэш', diff --git a/app/i18n/sk/sub.php b/app/i18n/sk/sub.php index f583f6ca0..3c980d202 100644 --- a/app/i18n/sk/sub.php +++ b/app/i18n/sk/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath pre:', ), 'rss' => 'RSS / Atom (prednastavené)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Vymazať vyrovnáciu pamäť', diff --git a/app/i18n/tr/sub.php b/app/i18n/tr/sub.php index 056c059ac..3e03f667c 100644 --- a/app/i18n/tr/sub.php +++ b/app/i18n/tr/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath:', ), 'rss' => 'RSS / Atom (varsayılan)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => 'Önbelleği temizle', diff --git a/app/i18n/zh-cn/sub.php b/app/i18n/zh-cn/sub.php index 2f9d17ace..5e6e570a9 100644 --- a/app/i18n/zh-cn/sub.php +++ b/app/i18n/zh-cn/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath 定位:', ), 'rss' => 'RSS / Atom (默认)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => '清理缓存', diff --git a/app/i18n/zh-tw/sub.php b/app/i18n/zh-tw/sub.php index dddcb2661..8a255645d 100644 --- a/app/i18n/zh-tw/sub.php +++ b/app/i18n/zh-tw/sub.php @@ -122,6 +122,7 @@ return array( 'xpath' => 'XPath 定位:', ), 'rss' => 'RSS / Atom (默認)', + 'xml_xpath' => 'XML + XPath', // TODO ), 'maintenance' => array( 'clear_cache' => '清理暫存', diff --git a/app/views/helpers/export/opml.phtml b/app/views/helpers/export/opml.phtml index eb6f7523b..64c83c960 100644 --- a/app/views/helpers/export/opml.phtml +++ b/app/views/helpers/export/opml.phtml @@ -18,8 +18,15 @@ function feedsToOutlines($feeds, $excludeMutedFeeds = false): array { 'description' => htmlspecialchars_decode($feed->description(), ENT_QUOTES), ]; - if ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH) { - $outline['type'] = FreshRSS_Export_Service::TYPE_HTML_XPATH; + if ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH || $feed->kind() === FreshRSS_Feed::KIND_XML_XPATH) { + switch ($feed->kind()) { + case FreshRSS_Feed::KIND_HTML_XPATH: + $outline['type'] = FreshRSS_Export_Service::TYPE_HTML_XPATH; + break; + case FreshRSS_Feed::KIND_XML_XPATH: + $outline['type'] = FreshRSS_Export_Service::TYPE_XML_XPATH; + break; + } /** @var array */ $xPathSettings = $feed->attributes('xpath'); $outline['frss:xPathItem'] = $xPathSettings['item'] ?? null; diff --git a/app/views/helpers/feed/update.phtml b/app/views/helpers/feed/update.phtml index 5b958451d..0cd2ec0c3 100644 --- a/app/views/helpers/feed/update.phtml +++ b/app/views/helpers/feed/update.phtml @@ -391,8 +391,9 @@
    diff --git a/app/views/subscription/add.phtml b/app/views/subscription/add.phtml index 7fa59e751..4e9da877f 100644 --- a/app/views/subscription/add.phtml +++ b/app/views/subscription/add.phtml @@ -70,6 +70,7 @@ diff --git a/docs/en/developers/OPML.md b/docs/en/developers/OPML.md index 2190a1de3..f65fd2faa 100644 --- a/docs/en/developers/OPML.md +++ b/docs/en/developers/OPML.md @@ -17,12 +17,14 @@ FreshRSS uses the XML namespace to export/import ext The list of the custom FreshRSS attributes can be seen in [the source code](https://github.com/FreshRSS/FreshRSS/blob/edge/app/views/helpers/export/opml.phtml), and here is an overview: -### HTML+XPath +### HTML+XPath or XML+XPath * ` ℹ️ [XPath 1.0](https://en.wikipedia.org/wiki/XPath) is a standard query language, which FreshRSS supports to enable [Web scraping](https://en.wikipedia.org/wiki/Web_scraping). +* ` $attributes */ function httpGet(string $url, string $cachePath, string $type = 'html', array $attributes = []): string { @@ -439,9 +443,15 @@ function httpGet(string $url, string $cachePath, string $type = 'html', array $a $accept = '*/*;q=0.8'; switch ($type) { + case 'json': + $accept = 'application/json,application/javascript;q=0.9,text/javascript;q=0.8,*/*;q=0.7'; + break; case 'opml': $accept = 'text/x-opml,text/xml;q=0.9,application/xml;q=0.9,*/*;q=0.8'; break; + case 'xml': + $accept = 'application/xml,application/xhtml+xml,text/xml;q=0.9,*/*;q=0.8'; + break; case 'html': default: $accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'; diff --git a/p/scripts/feed.js b/p/scripts/feed.js index 1a6833db6..29af2a3ea 100644 --- a/p/scripts/feed.js +++ b/p/scripts/feed.js @@ -88,10 +88,17 @@ function init_disable_elements_on_update(parent) { function init_select_show(parent) { const listener = (select) => { const options = select.querySelectorAll('option[data-show]'); + const shows = {}; // To allow multiple options to show the same element for (const option of options) { - const elem = document.getElementById(option.dataset.show); + if (!shows[option.dataset.show]) { + shows[option.dataset.show] = option.selected; + } + } + + for (const show in shows) { + const elem = document.getElementById(show); if (elem) { - elem.style.display = option.selected ? 'block' : 'none'; + elem.style.display = shows[show] ? 'block' : 'none'; } } }; -- cgit v1.2.3