aboutsummaryrefslogtreecommitdiffstats
path: root/includes/Feed
diff options
context:
space:
mode:
authorAmir Sarabadani <ladsgroup@gmail.com>2022-10-17 13:10:57 +0200
committerKrinkle <krinkle@fastmail.com>2022-10-20 17:25:49 +0000
commitf8bf3687f4e2ea32a3c5c7ac8492e6f573b51e9a (patch)
tree93ec660b854dc2a4720a95535ca1f83916a98f4c /includes/Feed
parent68521d08ffd0c6fbf0b5adf8be4cd5e831ea20bf (diff)
downloadmediawikicore-f8bf3687f4e2ea32a3c5c7ac8492e6f573b51e9a.tar.gz
mediawikicore-f8bf3687f4e2ea32a3c5c7ac8492e6f573b51e9a.zip
Feed: Move feed-related classes to Feed/ and namespace them
Bug: T166010 Change-Id: Icdbe003e74d2f31b68b575acfa94c09c24d7aed5
Diffstat (limited to 'includes/Feed')
-rw-r--r--includes/Feed/AtomFeed.php120
-rw-r--r--includes/Feed/ChannelFeed.php147
-rw-r--r--includes/Feed/FeedItem.php235
-rw-r--r--includes/Feed/FeedUtils.php324
-rw-r--r--includes/Feed/RSSFeed.php96
5 files changed, 922 insertions, 0 deletions
diff --git a/includes/Feed/AtomFeed.php b/includes/Feed/AtomFeed.php
new file mode 100644
index 000000000000..4574a6230c66
--- /dev/null
+++ b/includes/Feed/AtomFeed.php
@@ -0,0 +1,120 @@
+<?php
+
+/**
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Feed;
+
+use MediaWiki\MainConfigNames;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Generate an Atom feed.
+ *
+ * @ingroup Feed
+ */
+class AtomFeed extends ChannelFeed {
+ /**
+ * Format a date given timestamp, if one is given.
+ *
+ * @param string|int|null $timestamp
+ * @return string|null
+ */
+ private function formatTime( $timestamp ) {
+ if ( $timestamp ) {
+ // need to use RFC 822 time format at least for rss2.0
+ return gmdate( 'Y-m-d\TH:i:s', (int)wfTimestamp( TS_UNIX, $timestamp ) );
+ }
+ return null;
+ }
+
+ /**
+ * Outputs a basic header for Atom 1.0 feeds.
+ */
+ public function outHeader() {
+ $this->outXmlHeader();
+ // Manually escaping rather than letting Mustache do it because Mustache
+ // uses htmlentities, which does not work with XML
+ $templateParams = [
+ 'language' => $this->xmlEncode( $this->getLanguage() ),
+ 'feedID' => $this->getFeedId(),
+ 'title' => $this->getTitle(),
+ 'url' => $this->xmlEncode( wfExpandUrl( $this->getUrlUnescaped(), PROTO_CURRENT ) ),
+ 'selfUrl' => $this->getSelfUrl(),
+ 'timestamp' => $this->xmlEncode( $this->formatTime( wfTimestampNow() ) ),
+ 'description' => $this->getDescription(),
+ 'version' => $this->xmlEncode( MW_VERSION ),
+ ];
+ print $this->templateParser->processTemplate( 'AtomHeader', $templateParams );
+ }
+
+ /**
+ * Atom 1.0 requires a unique, opaque IRI as a unique identifier
+ * for every feed we create. For now just use the URL, but who
+ * can tell if that's right? If we put options on the feed, do we
+ * have to change the id? Maybe? Maybe not.
+ *
+ * @return string
+ */
+ private function getFeedId() {
+ return $this->getSelfUrl();
+ }
+
+ /**
+ * Atom 1.0 requests a self-reference to the feed.
+ * @return string
+ */
+ private function getSelfUrl() {
+ global $wgRequest;
+ return htmlspecialchars( $wgRequest->getFullRequestURL() );
+ }
+
+ /**
+ * Output a given item.
+ * @param FeedItem $item
+ */
+ public function outItem( $item ) {
+ $mimeType = MediaWikiServices::getInstance()->getMainConfig()
+ ->get( MainConfigNames::MimeType );
+ // Manually escaping rather than letting Mustache do it because Mustache
+ // uses htmlentities, which does not work with XML
+ $templateParams = [
+ "uniqueID" => $item->getUniqueID(),
+ "title" => $item->getTitle(),
+ "mimeType" => $this->xmlEncode( $mimeType ),
+ "url" => $this->xmlEncode( wfExpandUrl( $item->getUrlUnescaped(), PROTO_CURRENT ) ),
+ "date" => $this->xmlEncode( $this->formatTime( $item->getDate() ) ),
+ "description" => $item->getDescription(),
+ "author" => $item->getAuthor()
+ ];
+ print $this->templateParser->processTemplate( 'AtomItem', $templateParams );
+ }
+
+ /**
+ * Outputs the footer for Atom 1.0 feed (basically '\</feed\>').
+ */
+ public function outFooter() {
+ print "</feed>";
+ }
+}
+
+class_alias( AtomFeed::class, 'AtomFeed' );
diff --git a/includes/Feed/ChannelFeed.php b/includes/Feed/ChannelFeed.php
new file mode 100644
index 000000000000..2a58ba90784a
--- /dev/null
+++ b/includes/Feed/ChannelFeed.php
@@ -0,0 +1,147 @@
+<?php
+
+/**
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Feed;
+
+use MediaWiki\MainConfigNames;
+use MediaWiki\MediaWikiServices;
+use TemplateParser;
+use Title;
+
+/**
+ * Class to support the outputting of syndication feeds in Atom and RSS format.
+ *
+ * @stable to extend
+ * @ingroup Feed
+ */
+abstract class ChannelFeed extends FeedItem {
+
+ /** @var TemplateParser */
+ protected $templateParser;
+
+ /**
+ * @stable to call
+ *
+ * @param string|Title $title Feed's title
+ * @param string $description
+ * @param string $url URL uniquely designating the feed.
+ * @param string $date Feed's date
+ * @param string $author Author's user name
+ * @param string $comments
+ *
+ */
+ public function __construct(
+ $title, $description, $url, $date = '', $author = '', $comments = ''
+ ) {
+ parent::__construct( $title, $description, $url, $date, $author, $comments );
+ $this->templateParser = new TemplateParser();
+ }
+
+ /**
+ * Generate Header of the feed
+ * @par Example:
+ * @code
+ * print "<feed>";
+ * @endcode
+ */
+ abstract public function outHeader();
+
+ /**
+ * Generate an item
+ * @par Example:
+ * @code
+ * print "<item>...</item>";
+ * @endcode
+ * @param FeedItem $item
+ */
+ abstract public function outItem( $item );
+
+ /**
+ * Generate Footer of the feed
+ * @par Example:
+ * @code
+ * print "</feed>";
+ * @endcode
+ */
+ abstract public function outFooter();
+
+ /**
+ * Setup and send HTTP headers. Don't send any content;
+ * content might end up being cached and re-sent with
+ * these same headers later.
+ *
+ * This should be called from the outHeader() method,
+ * but can also be called separately.
+ */
+ public function httpHeaders() {
+ global $wgOut;
+ $varyOnXFP = MediaWikiServices::getInstance()->getMainConfig()
+ ->get( MainConfigNames::VaryOnXFP );
+ # We take over from $wgOut, excepting its cache header info
+ $wgOut->disable();
+ $mimetype = $this->contentType();
+ header( "Content-type: $mimetype; charset=UTF-8" );
+
+ // Set a sensible filename
+ $mimeAnalyzer = MediaWikiServices::getInstance()->getMimeAnalyzer();
+ $ext = $mimeAnalyzer->getExtensionFromMimeTypeOrNull( $mimetype ) ?? 'xml';
+ header( "Content-Disposition: inline; filename=\"feed.{$ext}\"" );
+
+ if ( $varyOnXFP ) {
+ $wgOut->addVaryHeader( 'X-Forwarded-Proto' );
+ }
+ $wgOut->sendCacheControl();
+ }
+
+ /**
+ * Return an internet media type to be sent in the headers.
+ *
+ * @stable to override
+ *
+ * @return string
+ */
+ private function contentType() {
+ global $wgRequest;
+
+ $ctype = $wgRequest->getVal( 'ctype', 'application/xml' );
+ $allowedctypes = [
+ 'application/xml',
+ 'text/xml',
+ 'application/rss+xml',
+ 'application/atom+xml'
+ ];
+
+ return ( in_array( $ctype, $allowedctypes ) ? $ctype : 'application/xml' );
+ }
+
+ /**
+ * Output the initial XML headers.
+ */
+ protected function outXmlHeader() {
+ $this->httpHeaders();
+ echo '<?xml version="1.0"?>' . "\n";
+ }
+}
+
+class_alias( ChannelFeed::class, 'ChannelFeed' );
diff --git a/includes/Feed/FeedItem.php b/includes/Feed/FeedItem.php
new file mode 100644
index 000000000000..2968b964e228
--- /dev/null
+++ b/includes/Feed/FeedItem.php
@@ -0,0 +1,235 @@
+<?php
+/**
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Feed;
+
+use LanguageCode;
+use MediaWiki\MainConfigNames;
+use MediaWiki\MediaWikiServices;
+use Title;
+
+/**
+ * @defgroup Feed Feed
+ */
+
+/**
+ * A base class for outputting syndication feeds (e.g. RSS and other formats).
+ *
+ * @ingroup Feed
+ */
+class FeedItem {
+ /** @var Title */
+ public $title;
+
+ public $description;
+
+ public $url;
+
+ public $date;
+
+ public $author;
+
+ public $uniqueId;
+
+ public $comments;
+
+ public $rssIsPermalink = false;
+
+ /**
+ * @param string|Title $title Item's title
+ * @param string $description
+ * @param string $url URL uniquely designating the item.
+ * @param string $date Item's date
+ * @param string $author Author's user name
+ * @param string $comments
+ */
+ public function __construct(
+ $title, $description, $url, $date = '', $author = '', $comments = ''
+ ) {
+ $this->title = $title;
+ $this->description = $description;
+ $this->url = $url;
+ $this->uniqueId = $url;
+ $this->date = $date;
+ $this->author = $author;
+ $this->comments = $comments;
+ }
+
+ /**
+ * Encode $string so that it can be safely embedded in a XML document
+ *
+ * @param string $string String to encode
+ * @return string
+ */
+ public function xmlEncode( $string ) {
+ $string = str_replace( "\r\n", "\n", $string );
+ $string = preg_replace( '/[\x00-\x08\x0b\x0c\x0e-\x1f]/', '', $string );
+ return htmlspecialchars( $string );
+ }
+
+ /**
+ * Get the unique id of this item; already xml-encoded
+ * @return string
+ */
+ public function getUniqueID() {
+ $id = $this->getUniqueIdUnescaped();
+ if ( $id ) {
+ return $this->xmlEncode( $id );
+ }
+ }
+
+ /**
+ * Get the unique id of this item, without any escaping
+ * @return string
+ */
+ public function getUniqueIdUnescaped() {
+ if ( $this->uniqueId ) {
+ return wfExpandUrl( $this->uniqueId, PROTO_CURRENT );
+ }
+ }
+
+ /**
+ * Set the unique id of an item
+ *
+ * @param string $uniqueId Unique id for the item
+ * @param bool $rssIsPermalink Set to true if the guid (unique id) is a permalink (RSS feeds only)
+ */
+ public function setUniqueId( $uniqueId, $rssIsPermalink = false ) {
+ $this->uniqueId = $uniqueId;
+ $this->rssIsPermalink = $rssIsPermalink;
+ }
+
+ /**
+ * Get the title of this item; already xml-encoded
+ *
+ * @return string
+ */
+ public function getTitle() {
+ return $this->xmlEncode( $this->title );
+ }
+
+ /**
+ * Get the URL of this item; already xml-encoded
+ *
+ * @return string
+ */
+ public function getUrl() {
+ return $this->xmlEncode( $this->url );
+ }
+
+ /** Get the URL of this item without any escaping
+ *
+ * @return string
+ */
+ public function getUrlUnescaped() {
+ return $this->url;
+ }
+
+ /**
+ * Get the description of this item; already xml-encoded
+ *
+ * @return string
+ */
+ public function getDescription() {
+ return $this->xmlEncode( $this->description );
+ }
+
+ /**
+ * Get the description of this item without any escaping
+ *
+ * @return string
+ */
+ public function getDescriptionUnescaped() {
+ return $this->description;
+ }
+
+ /**
+ * Get the language of this item
+ *
+ * @return string
+ */
+ public function getLanguage() {
+ $languageCode = MediaWikiServices::getInstance()->getMainConfig()
+ ->get( MainConfigNames::LanguageCode );
+ return LanguageCode::bcp47( $languageCode );
+ }
+
+ /**
+ * Get the date of this item
+ *
+ * @return string
+ */
+ public function getDate() {
+ return $this->date;
+ }
+
+ /**
+ * Get the author of this item; already xml-encoded
+ *
+ * @return string
+ */
+ public function getAuthor() {
+ return $this->xmlEncode( $this->author );
+ }
+
+ /**
+ * Get the author of this item without any escaping
+ *
+ * @return string
+ */
+ public function getAuthorUnescaped() {
+ return $this->author;
+ }
+
+ /**
+ * Get the comment of this item; already xml-encoded
+ *
+ * @return string
+ */
+ public function getComments() {
+ return $this->xmlEncode( $this->comments );
+ }
+
+ /**
+ * Get the comment of this item without any escaping
+ *
+ * @return string
+ */
+ public function getCommentsUnescaped() {
+ return $this->comments;
+ }
+
+ /**
+ * Quickie hack... strip out wikilinks to more legible form from the comment.
+ *
+ * @param string $text Wikitext
+ * @return string
+ */
+ public static function stripComment( $text ) {
+ return preg_replace( '/\[\[([^]]*\|)?([^]]+)\]\]/', '\2', $text );
+ }
+
+ /** #@- */
+}
+
+class_alias( FeedItem::class, 'FeedItem' );
diff --git a/includes/Feed/FeedUtils.php b/includes/Feed/FeedUtils.php
new file mode 100644
index 000000000000..2e818f680673
--- /dev/null
+++ b/includes/Feed/FeedUtils.php
@@ -0,0 +1,324 @@
+<?php
+/**
+ * Helper functions for feeds.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Feed
+ */
+
+namespace MediaWiki\Feed;
+
+use CommentStore;
+use Html;
+use Linker;
+use LogFormatter;
+use MediaWiki\MainConfigNames;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
+use OutputPage;
+use RequestContext;
+use TextContent;
+use Title;
+use User;
+use UtfNormal;
+
+/**
+ * Helper functions for feeds
+ *
+ * @ingroup Feed
+ */
+class FeedUtils {
+
+ /**
+ * Check whether feeds can be used and that $type is a valid feed type
+ *
+ * @param string $type Feed type, as requested by the user
+ * @param OutputPage|null $output Null falls back to $wgOut
+ * @return bool
+ * @since 1.36 $output parameter added
+ *
+ */
+ public static function checkFeedOutput( $type, $output = null ) {
+ $feed = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::Feed );
+ $feedClasses = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::FeedClasses );
+ if ( $output === null ) {
+ // Todo update GoogleNewsSitemap and deprecate
+ global $wgOut;
+ $output = $wgOut;
+ }
+
+ if ( !$feed ) {
+ $output->addWikiMsg( 'feed-unavailable' );
+ return false;
+ }
+
+ if ( !isset( $feedClasses[$type] ) ) {
+ $output->addWikiMsg( 'feed-invalid' );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Format a diff for the newsfeed
+ *
+ * @param \stdClass $row Row from the recentchanges table, including fields as
+ * appropriate for CommentStore
+ * @param string|null $formattedComment rc_comment in HTML format, or null
+ * to format it on demand.
+ * @return string
+ */
+ public static function formatDiff( $row, $formattedComment = null ) {
+ $titleObj = Title::makeTitle( $row->rc_namespace, $row->rc_title );
+ $timestamp = wfTimestamp( TS_MW, $row->rc_timestamp );
+ $actiontext = '';
+ if ( $row->rc_type == RC_LOG ) {
+ $rcRow = (array)$row; // newFromRow() only accepts arrays for RC rows
+ $actiontext = LogFormatter::newFromRow( $rcRow )->getActionText();
+ }
+ if ( $row->rc_deleted & RevisionRecord::DELETED_COMMENT ) {
+ $formattedComment = wfMessage( 'rev-deleted-comment' )->escaped();
+ } elseif ( $formattedComment === null ) {
+ $formattedComment = Linker::formatComment(
+ CommentStore::getStore()->getComment( 'rc_comment', $row )->text );
+ }
+ return self::formatDiffRow2( $titleObj,
+ $row->rc_last_oldid, $row->rc_this_oldid,
+ $timestamp,
+ $formattedComment,
+ $actiontext
+ );
+ }
+
+ /**
+ * Really format a diff for the newsfeed
+ *
+ * @param Title $title
+ * @param int $oldid Old revision's id
+ * @param int $newid New revision's id
+ * @param string $timestamp New revision's timestamp
+ * @param string $comment New revision's comment
+ * @param string $actiontext Text of the action; in case of log event
+ * @return string
+ * @deprecated since 1.38 use formatDiffRow2
+ *
+ */
+ public static function formatDiffRow( $title, $oldid, $newid, $timestamp,
+ $comment, $actiontext = ''
+ ) {
+ $formattedComment = MediaWikiServices::getInstance()->getCommentFormatter()
+ ->format( $comment );
+ return self::formatDiffRow2( $title, $oldid, $newid, $timestamp,
+ $formattedComment, $actiontext );
+ }
+
+ /**
+ * Really really format a diff for the newsfeed. Same as formatDiffRow()
+ * except with preformatted comments.
+ *
+ * @param Title $title
+ * @param int $oldid Old revision's id
+ * @param int $newid New revision's id
+ * @param string $timestamp New revision's timestamp
+ * @param string $formattedComment New revision's comment in HTML format
+ * @param string $actiontext Text of the action; in case of log event
+ * @return string
+ */
+ public static function formatDiffRow2( $title, $oldid, $newid, $timestamp,
+ $formattedComment, $actiontext = ''
+ ) {
+ $feedDiffCutoff = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::FeedDiffCutoff );
+
+ // log entries
+ $unwrappedText = implode(
+ ' ',
+ array_filter( [ $actiontext, $formattedComment ] )
+ );
+ $completeText = Html::rawElement( 'p', [], $unwrappedText ) . "\n";
+
+ // NOTE: Check permissions for anonymous users, not current user.
+ // No "privileged" version should end up in the cache.
+ // Most feed readers will not log in anyway.
+ $anon = new User();
+ $services = MediaWikiServices::getInstance();
+ $permManager = $services->getPermissionManager();
+ $accErrors = $permManager->getPermissionErrors(
+ 'read',
+ $anon,
+ $title
+ );
+
+ // Can't diff special pages, unreadable pages or pages with no new revision
+ // to compare against: just return the text.
+ if ( $title->getNamespace() < 0 || $accErrors || !$newid ) {
+ return $completeText;
+ }
+
+ $revLookup = $services->getRevisionLookup();
+ $contentHandlerFactory = $services->getContentHandlerFactory();
+ if ( $oldid ) {
+ $diffText = '';
+ // Don't bother generating the diff if we won't be able to show it
+ if ( $feedDiffCutoff > 0 ) {
+ $revRecord = $revLookup->getRevisionById( $oldid );
+
+ if ( !$revRecord ) {
+ $diffText = false;
+ } else {
+ $mainContext = RequestContext::getMain();
+ $context = clone RequestContext::getMain();
+ $context->setTitle( $title );
+
+ $model = $revRecord->getSlot(
+ SlotRecord::MAIN,
+ RevisionRecord::RAW
+ )->getModel();
+ $contentHandler = $contentHandlerFactory->getContentHandler( $model );
+ $de = $contentHandler->createDifferenceEngine( $context, $oldid, $newid );
+ $lang = $mainContext->getLanguage();
+ $user = $mainContext->getUser();
+ $diffText = $de->getDiff(
+ $mainContext->msg( 'previousrevision' )->text(), // hack
+ $mainContext->msg( 'revisionasof',
+ $lang->userTimeAndDate( $timestamp, $user ),
+ $lang->userDate( $timestamp, $user ),
+ $lang->userTime( $timestamp, $user ) )->text() );
+ }
+ }
+
+ if ( $feedDiffCutoff <= 0 || ( strlen( $diffText ) > $feedDiffCutoff ) ) {
+ // Omit large diffs
+ $diffText = self::getDiffLink( $title, $newid, $oldid );
+ } elseif ( $diffText === false ) {
+ // Error in diff engine, probably a missing revision
+ $diffText = Html::rawElement(
+ 'p',
+ [],
+ "Can't load revision $newid"
+ );
+ } else {
+ // Diff output fine, clean up any illegal UTF-8
+ $diffText = UtfNormal\Validator::cleanUp( $diffText );
+ $diffText = self::applyDiffStyle( $diffText );
+ }
+ } else {
+ $revRecord = $revLookup->getRevisionById( $newid );
+ if ( $feedDiffCutoff <= 0 || $revRecord === null ) {
+ $newContent = $contentHandlerFactory
+ ->getContentHandler( $title->getContentModel() )
+ ->makeEmptyContent();
+ } else {
+ $newContent = $revRecord->getContent( SlotRecord::MAIN );
+ }
+
+ if ( $newContent instanceof TextContent ) {
+ // only textual content has a "source view".
+ $text = $newContent->getText();
+
+ if ( $feedDiffCutoff <= 0 || strlen( $text ) > $feedDiffCutoff ) {
+ $html = null;
+ } else {
+ $html = nl2br( htmlspecialchars( $text ) );
+ }
+ } else {
+ // XXX: we could get an HTML representation of the content via getParserOutput, but that may
+ // contain JS magic and generally may not be suitable for inclusion in a feed.
+ // Perhaps Content should have a getDescriptiveHtml method and/or a getSourceText method.
+ // Compare also ApiFeedContributions::feedItemDesc
+ $html = null;
+ }
+
+ if ( $html === null ) {
+ // Omit large new page diffs, T31110
+ // Also use diff link for non-textual content
+ $diffText = self::getDiffLink( $title, $newid );
+ } else {
+ $diffText = Html::rawElement(
+ 'p',
+ [],
+ Html::rawElement( 'b', [], wfMessage( 'newpage' )->text() )
+ );
+ $diffText .= Html::rawElement( 'div', [], $html );
+ }
+ }
+ $completeText .= $diffText;
+
+ return $completeText;
+ }
+
+ /**
+ * Generates a diff link. Used when the full diff is not wanted for example
+ * when $wgFeedDiffCutoff is 0.
+ *
+ * @param Title $title Title object: used to generate the diff URL
+ * @param int $newid Newid for this diff
+ * @param int|null $oldid Oldid for the diff. Null means it is a new article
+ * @return string
+ */
+ protected static function getDiffLink( Title $title, $newid, $oldid = null ) {
+ $queryParameters = [ 'diff' => $newid ];
+ if ( $oldid != null ) {
+ $queryParameters['oldid'] = $oldid;
+ }
+ $diffUrl = $title->getFullURL( $queryParameters );
+
+ $diffLink = Html::element( 'a', [ 'href' => $diffUrl ],
+ wfMessage( 'showdiff' )->inContentLanguage()->text() );
+
+ return $diffLink;
+ }
+
+ /**
+ * Hacky application of diff styles for the feeds.
+ * Might be 'cleaner' to use DOM or XSLT or something,
+ * but *gack* it's a pain in the ass.
+ *
+ * @param string $text Diff's HTML output
+ * @return string Modified HTML
+ */
+ public static function applyDiffStyle( $text ) {
+ $styles = [
+ 'diff' => 'background-color: #fff; color: #202122;',
+ 'diff-otitle' => 'background-color: #fff; color: #202122; text-align: center;',
+ 'diff-ntitle' => 'background-color: #fff; color: #202122; text-align: center;',
+ 'diff-addedline' => 'color: #202122; font-size: 88%; border-style: solid; '
+ . 'border-width: 1px 1px 1px 4px; border-radius: 0.33em; border-color: #a3d3ff; '
+ . 'vertical-align: top; white-space: pre-wrap;',
+ 'diff-deletedline' => 'color: #202122; font-size: 88%; border-style: solid; '
+ . 'border-width: 1px 1px 1px 4px; border-radius: 0.33em; border-color: #ffe49c; '
+ . 'vertical-align: top; white-space: pre-wrap;',
+ 'diff-context' => 'background-color: #f8f9fa; color: #202122; font-size: 88%; '
+ . 'border-style: solid; border-width: 1px 1px 1px 4px; border-radius: 0.33em; '
+ . 'border-color: #eaecf0; vertical-align: top; white-space: pre-wrap;',
+ 'diffchange' => 'font-weight: bold; text-decoration: none;',
+ ];
+
+ foreach ( $styles as $class => $style ) {
+ $text = preg_replace( '/(<\w+\b[^<>]*)\bclass=([\'"])(?:[^\'"]*\s)?' .
+ preg_quote( $class ) . '(?:\s[^\'"]*)?\2(?=[^<>]*>)/',
+ '$1style="' . $style . '"', $text );
+ }
+
+ return $text;
+ }
+
+}
+
+class_alias( FeedUtils::class, 'FeedUtils' );
diff --git a/includes/Feed/RSSFeed.php b/includes/Feed/RSSFeed.php
new file mode 100644
index 000000000000..1e3efa727c83
--- /dev/null
+++ b/includes/Feed/RSSFeed.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * Copyright © 2004 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Feed;
+
+/**
+ * Generate an RSS feed.
+ *
+ * @ingroup Feed
+ */
+class RSSFeed extends ChannelFeed {
+
+ /**
+ * Format a date given a timestamp. If a timestamp is not given, nothing is returned
+ *
+ * @param string|int|null $ts Timestamp
+ * @return string|null Date string
+ */
+ private function formatTime( $ts ) {
+ if ( $ts ) {
+ return gmdate( 'D, d M Y H:i:s \G\M\T', (int)wfTimestamp( TS_UNIX, $ts ) );
+ }
+ return null;
+ }
+
+ /**
+ * Output an RSS 2.0 header
+ */
+ public function outHeader() {
+ $this->outXmlHeader();
+ // Manually escaping rather than letting Mustache do it because Mustache
+ // uses htmlentities, which does not work with XML
+ $templateParams = [
+ 'title' => $this->getTitle(),
+ 'url' => $this->xmlEncode( wfExpandUrl( $this->getUrlUnescaped(), PROTO_CURRENT ) ),
+ 'description' => $this->getDescription(),
+ 'language' => $this->xmlEncode( $this->getLanguage() ),
+ 'version' => $this->xmlEncode( MW_VERSION ),
+ 'timestamp' => $this->xmlEncode( $this->formatTime( wfTimestampNow() ) )
+ ];
+ print $this->templateParser->processTemplate( 'RSSHeader', $templateParams );
+ }
+
+ /**
+ * Output an RSS 2.0 item
+ * @param FeedItem $item Item to be output
+ */
+ public function outItem( $item ) {
+ // Manually escaping rather than letting Mustache do it because Mustache
+ // uses htmlentities, which does not work with XML
+ $templateParams = [
+ "title" => $item->getTitle(),
+ "url" => $this->xmlEncode( wfExpandUrl( $item->getUrlUnescaped(), PROTO_CURRENT ) ),
+ "permalink" => $item->rssIsPermalink,
+ "uniqueID" => $item->getUniqueID(),
+ "description" => $item->getDescription(),
+ "date" => $this->xmlEncode( $this->formatTime( $item->getDate() ) ),
+ "author" => $item->getAuthor()
+ ];
+ $comments = $item->getCommentsUnescaped();
+ if ( $comments ) {
+ $commentsEscaped = $this->xmlEncode( wfExpandUrl( $comments, PROTO_CURRENT ) );
+ $templateParams["comments"] = $commentsEscaped;
+ }
+ print $this->templateParser->processTemplate( 'RSSItem', $templateParams );
+ }
+
+ /**
+ * Output an RSS 2.0 footer
+ */
+ public function outFooter() {
+ print "</channel></rss>";
+ }
+}
+
+class_alias( RSSFeed::class, 'RSSFeed' );