diff --git a/com.woltlab.wcf/templates/articleList.tpl b/com.woltlab.wcf/templates/articleList.tpl
index 8492fa0a294..518f45856fc 100644
--- a/com.woltlab.wcf/templates/articleList.tpl
+++ b/com.woltlab.wcf/templates/articleList.tpl
@@ -7,9 +7,9 @@
{/if}
{if $__wcf->getUser()->userID}
-
+
{else}
-
+
{/if}
{/capture}
@@ -104,7 +104,7 @@
{/capture}
{capture assign='contentInteractionDropdownItems'}
-
+
{/capture}
{include file='header'}
diff --git a/com.woltlab.wcf/templates/categoryArticleList.tpl b/com.woltlab.wcf/templates/categoryArticleList.tpl
index 392e7c8fadc..1db7eec872e 100644
--- a/com.woltlab.wcf/templates/categoryArticleList.tpl
+++ b/com.woltlab.wcf/templates/categoryArticleList.tpl
@@ -12,9 +12,9 @@
{/if}
{if $__wcf->getUser()->userID}
-
+
{else}
-
+
{/if}
{/capture}
@@ -69,7 +69,7 @@
{/capture}
{capture assign='contentInteractionDropdownItems'}
-
+
{/capture}
{include file='header'}
diff --git a/com.woltlab.wcf/templates/notificationList.tpl b/com.woltlab.wcf/templates/notificationList.tpl
index e5e849ec070..02660820d03 100644
--- a/com.woltlab.wcf/templates/notificationList.tpl
+++ b/com.woltlab.wcf/templates/notificationList.tpl
@@ -1,7 +1,7 @@
{capture assign='contentTitleBadge'}{#$__wcf->getUserNotificationHandler()->countAllNotifications()}{/capture}
{capture assign='headContent'}
-
+
{/capture}
{capture assign='contentInteractionPagination'}
@@ -15,7 +15,7 @@
{/capture}
{capture assign='contentInteractionDropdownItems'}
- {lang}wcf.global.button.rss{/lang}
+ {lang}wcf.global.button.rss{/lang}
{/capture}
{include file='header'}
diff --git a/wcfsetup/install/files/lib/data/IFeedEntry.class.php b/wcfsetup/install/files/lib/data/IFeedEntry.class.php
index 6d3698ea685..c59e6a22c47 100644
--- a/wcfsetup/install/files/lib/data/IFeedEntry.class.php
+++ b/wcfsetup/install/files/lib/data/IFeedEntry.class.php
@@ -8,6 +8,7 @@
* @author Tim Duesterhus
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
+ * @deprecated 6.1 use `wcf\system\rssFeed\RssFeedItem` instead
*/
interface IFeedEntry extends IMessage
{
diff --git a/wcfsetup/install/files/lib/data/IFeedEntryWithEnclosure.class.php b/wcfsetup/install/files/lib/data/IFeedEntryWithEnclosure.class.php
index 9ff7df58ec2..132cf8c5ec2 100644
--- a/wcfsetup/install/files/lib/data/IFeedEntryWithEnclosure.class.php
+++ b/wcfsetup/install/files/lib/data/IFeedEntryWithEnclosure.class.php
@@ -10,6 +10,7 @@
* @author Marcel Werk
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
+ * @deprecated 6.1 use `wcf\system\rssFeed\RssFeedItem` instead
*/
interface IFeedEntryWithEnclosure extends IFeedEntry
{
diff --git a/wcfsetup/install/files/lib/data/article/FeedArticle.class.php b/wcfsetup/install/files/lib/data/article/FeedArticle.class.php
index 5b839da19f6..a1ba081d5aa 100644
--- a/wcfsetup/install/files/lib/data/article/FeedArticle.class.php
+++ b/wcfsetup/install/files/lib/data/article/FeedArticle.class.php
@@ -14,6 +14,7 @@
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
* @since 3.0
+ * @deprecated 6.1
*/
class FeedArticle extends ViewableArticle implements IFeedEntryWithEnclosure
{
diff --git a/wcfsetup/install/files/lib/data/article/FeedArticleList.class.php b/wcfsetup/install/files/lib/data/article/FeedArticleList.class.php
index af6642e36c5..aa7926cf3b9 100644
--- a/wcfsetup/install/files/lib/data/article/FeedArticleList.class.php
+++ b/wcfsetup/install/files/lib/data/article/FeedArticleList.class.php
@@ -9,6 +9,7 @@
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
* @since 3.0
+ * @deprecated 6.1
*
* @method FeedArticle current()
* @method FeedArticle[] getObjects()
diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php
index 52f9965a6d9..d079ffbd2ea 100644
--- a/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php
+++ b/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php
@@ -87,18 +87,7 @@ public function getFormattedTeaser()
if ($this->teaser) {
return \nl2br(StringUtil::encodeHTML($this->teaser), false);
} else {
- $htmlOutputProcessor = new HtmlOutputProcessor();
- $htmlOutputProcessor->setOutputType('text/simplified-html');
- $htmlOutputProcessor->enableUgc = false;
- $htmlOutputProcessor->process(
- $this->content,
- 'com.woltlab.wcf.article.content',
- $this->articleContentID,
- false,
- $this->languageID
- );
-
- return MessageUtil::truncateFormattedMessage($htmlOutputProcessor->getHtml(), 500);
+ return MessageUtil::truncateFormattedMessage($this->getSimplifiedFormattedContent(), 500);
}
}
@@ -122,6 +111,26 @@ public function getFormattedContent()
return $processor->getHtml();
}
+ /**
+ * Returns a simplified version of the formatted content.
+ * @since 6.1
+ */
+ public function getSimplifiedFormattedContent(): string
+ {
+ $htmlOutputProcessor = new HtmlOutputProcessor();
+ $htmlOutputProcessor->setOutputType('text/simplified-html');
+ $htmlOutputProcessor->enableUgc = false;
+ $htmlOutputProcessor->process(
+ $this->content,
+ 'com.woltlab.wcf.article.content',
+ $this->articleContentID,
+ false,
+ $this->languageID
+ );
+
+ return $htmlOutputProcessor->getHtml();
+ }
+
/**
* Returns article object.
*
diff --git a/wcfsetup/install/files/lib/page/AbstractFeedPage.class.php b/wcfsetup/install/files/lib/page/AbstractFeedPage.class.php
index cbf9a54144c..6b2a7f85617 100644
--- a/wcfsetup/install/files/lib/page/AbstractFeedPage.class.php
+++ b/wcfsetup/install/files/lib/page/AbstractFeedPage.class.php
@@ -2,8 +2,10 @@
namespace wcf\page;
+use wcf\system\request\LinkHandler;
use wcf\system\WCF;
use wcf\util\ArrayUtil;
+use wcf\util\HeaderUtil;
/**
* Generates RSS 2-Feeds.
@@ -11,6 +13,7 @@
* @author Tim Duesterhus
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
+ * @deprecated 6.1 use `AbstractRssFeedPage` instead
*/
abstract class AbstractFeedPage extends AbstractAuthedPage
{
@@ -96,4 +99,26 @@ public function show()
// show template
WCF::getTPL()->display($this->templateName, $this->application, false);
}
+
+ protected function redirectToNewPage(string $className): void
+ {
+ $parameters = [];
+ $url = '';
+ if ($this->objectIDs !== []) {
+ if (\count($this->objectIDs) === 1) {
+ $parameters['id'] = \reset($this->objectIDs);
+ } else {
+ $url = 'id=' . \implode(',', $this->objectIDs);
+ }
+ }
+ if (isset($_REQUEST['at'])) {
+ $parameters['at'] = $_REQUEST['at'];
+ }
+ HeaderUtil::redirect(
+ LinkHandler::getInstance()->getControllerLink($className, $parameters, $url),
+ true,
+ false
+ );
+ exit;
+ }
}
diff --git a/wcfsetup/install/files/lib/page/AbstractRssFeedPage.class.php b/wcfsetup/install/files/lib/page/AbstractRssFeedPage.class.php
new file mode 100644
index 00000000000..95f70608e4f
--- /dev/null
+++ b/wcfsetup/install/files/lib/page/AbstractRssFeedPage.class.php
@@ -0,0 +1,78 @@
+
+ * @since 6.1
+ */
+abstract class AbstractRssFeedPage extends AbstractAuthedPage
+{
+ /**
+ * @inheritDoc
+ */
+ public $useTemplate = false;
+
+ /**
+ * parsed contents of $_REQUEST['id']
+ * @var int[]
+ */
+ public array $objectIDs = [];
+
+ #[\Override]
+ public function readParameters()
+ {
+ parent::readParameters();
+
+ if (isset($_REQUEST['id'])) {
+ if (\is_array($_REQUEST['id'])) {
+ // ?id[]=1337&id[]=9001
+ $this->objectIDs = ArrayUtil::toIntegerArray($_REQUEST['id']);
+ } else {
+ // ?id=1337 or ?id=1337,9001
+ $this->objectIDs = ArrayUtil::toIntegerArray(\explode(',', $_REQUEST['id']));
+ }
+ }
+ }
+
+ #[\Override]
+ public function show()
+ {
+ parent::show();
+ if ($this->getPsr7Response()) {
+ return;
+ }
+
+ $output = $this->getRssFeed()->render();
+
+ @\header('Content-Type: application/rss+xml; charset=UTF-8');
+
+ echo $output;
+ }
+
+ protected function getDefaultChannel(): RssFeedChannel
+ {
+ $channel = new RssFeedChannel();
+ $channel
+ ->title(WCF::getLanguage()->get(\PAGE_TITLE))
+ ->description(WCF::getLanguage()->get(\PAGE_DESCRIPTION))
+ ->link(WCF::getPath())
+ ->language(WCF::getLanguage()->getFixedLanguageCode())
+ ->pubDateFromTimestamp(\TIME_NOW)
+ ->lastBuildDateFromTimestamp(\TIME_NOW)
+ ->atomLinkSelf(WCF::getRequestURI());
+
+ return $channel;
+ }
+
+ protected abstract function getRssFeed(): RssFeed;
+}
diff --git a/wcfsetup/install/files/lib/page/ArticleFeedPage.class.php b/wcfsetup/install/files/lib/page/ArticleFeedPage.class.php
index d1692c44342..e0a47030be9 100644
--- a/wcfsetup/install/files/lib/page/ArticleFeedPage.class.php
+++ b/wcfsetup/install/files/lib/page/ArticleFeedPage.class.php
@@ -15,6 +15,7 @@
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
* @since 3.0
+ * @deprecated 6.1 use `ArticleRssFeedPage` instead
*/
class ArticleFeedPage extends AbstractFeedPage
{
@@ -47,6 +48,8 @@ public function readParameters()
throw new PermissionDeniedException();
}
}
+
+ $this->redirectToNewPage(ArticleRssFeedPage::class);
}
/**
diff --git a/wcfsetup/install/files/lib/page/ArticleRssFeedPage.class.php b/wcfsetup/install/files/lib/page/ArticleRssFeedPage.class.php
new file mode 100644
index 00000000000..bf4d575869a
--- /dev/null
+++ b/wcfsetup/install/files/lib/page/ArticleRssFeedPage.class.php
@@ -0,0 +1,111 @@
+
+ * @since 6.1
+ */
+class ArticleRssFeedPage extends AbstractRssFeedPage
+{
+ public ArticleCategory $category;
+ public int $categoryID = 0;
+ public AccessibleArticleList $articles;
+
+ #[\Override]
+ public function readParameters()
+ {
+ parent::readParameters();
+
+ if ($this->objectIDs !== []) {
+ $this->categoryID = \reset($this->objectIDs);
+ $this->category = ArticleCategory::getCategory($this->categoryID);
+ if ($this->category === null) {
+ throw new IllegalLinkException();
+ }
+ if (!$this->category->isAccessible()) {
+ throw new PermissionDeniedException();
+ }
+ }
+ }
+
+ #[\Override]
+ public function readData()
+ {
+ parent::readData();
+
+ if ($this->categoryID) {
+ $this->articles = new CategoryArticleList($this->categoryID);
+ } else {
+ $this->articles = new AccessibleArticleList();
+ }
+ $this->articles->sqlOrderBy = 'article.time ' . ARTICLE_SORT_ORDER;
+ $this->articles->sqlLimit = 20;
+ $this->articles->readObjects();
+ }
+
+ #[\Override]
+ protected function getRssFeed(): RssFeed
+ {
+ $feed = new RssFeed();
+ $channel = $this->getDefaultChannel();
+ if (isset($this->category)) {
+ $channel->title($this->category->getTitle());
+ $channel->description($this->category->getDecoratedObject()->getDescription());
+ } else {
+ $channel->title(WCF::getLanguage()->get('wcf.article.articles'));
+ }
+
+ if ($this->articles->valid()) {
+ $channel->lastBuildDateFromTimestamp($this->articles->current()->getTime());
+ }
+ $feed->channel($channel);
+
+ foreach ($this->articles as $article) {
+ $item = new RssFeedItem();
+ $item
+ ->title($article->getTitle())
+ ->link($article->getLink())
+ ->description(StringUtil::truncateHTML($article->getFormattedTeaser(), 255))
+ ->pubDateFromTimestamp($article->time)
+ ->creator($article->username)
+ ->guid($article->getLink())
+ ->contentEncoded($article->getArticleContent()->getSimplifiedFormattedContent())
+ ->slashComments($article->getArticleContent()->comments);
+
+ if ($article->getImage() !== null) {
+ $item->enclosure(
+ $article->getImage()->getThumbnailLink('small'),
+ $article->getImage()->smallThumbnailSize,
+ $article->getImage()->smallThumbnailType
+ );
+ }
+
+ $category = $article->getDecoratedObject()->getCategory();
+ if ($category !== null) {
+ $item->category($category->getTitle());
+ foreach ($category->getParentCategories() as $category) {
+ $item->category($category->getTitle());
+ }
+ }
+
+ $channel->item($item);
+ }
+
+ return $feed;
+ }
+}
diff --git a/wcfsetup/install/files/lib/page/NotificationFeedPage.class.php b/wcfsetup/install/files/lib/page/NotificationFeedPage.class.php
index 1a52b1ad1b2..e4c25a79e08 100644
--- a/wcfsetup/install/files/lib/page/NotificationFeedPage.class.php
+++ b/wcfsetup/install/files/lib/page/NotificationFeedPage.class.php
@@ -13,6 +13,7 @@
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
* @since 3.0
+ * @deprecated 6.1 use `NotificationRssFeedPage` instead
*/
class NotificationFeedPage extends AbstractFeedPage
{
@@ -28,6 +29,8 @@ public function readParameters()
}
$this->title = WCF::getLanguage()->get('wcf.user.menu.community.notification');
+
+ $this->redirectToNewPage(NotificationRssFeedPage::class);
}
/**
diff --git a/wcfsetup/install/files/lib/page/NotificationRssFeedPage.class.php b/wcfsetup/install/files/lib/page/NotificationRssFeedPage.class.php
new file mode 100644
index 00000000000..f2ff35b4aa2
--- /dev/null
+++ b/wcfsetup/install/files/lib/page/NotificationRssFeedPage.class.php
@@ -0,0 +1,63 @@
+
+ * @since 6.1
+ */
+class NotificationRssFeedPage extends AbstractRssFeedPage
+{
+ #[\Override]
+ public function readParameters()
+ {
+ parent::readParameters();
+
+ if (!WCF::getUser()->userID) {
+ throw new IllegalLinkException();
+ }
+ }
+
+ #[\Override]
+ protected function getRssFeed(): RssFeed
+ {
+ $feed = new RssFeed();
+ $channel = $this->getDefaultChannel();
+ $channel->title(WCF::getLanguage()->get('wcf.user.menu.community.notification'));
+ $feed->channel($channel);
+
+ $notifications = UserNotificationHandler::getInstance()->getNotifications(20);
+ if ($notifications['notifications'] !== []) {
+ $channel->lastBuildDateFromTimestamp($notifications['notifications'][0]['time']);
+ }
+
+ foreach ($notifications['notifications'] as $notification) {
+ $event = $notification['event'];
+ \assert($event instanceof AbstractUserNotificationEvent);
+
+ $item = new RssFeedItem();
+ $item
+ ->title($event->getTitle())
+ ->link($event->getLink())
+ ->description($event->getExcerpt())
+ ->pubDateFromTimestamp($event->getTime())
+ ->creator($event->getAuthor()->username)
+ ->guid($event->getLink())
+ ->contentEncoded($event->getFormattedMessage());
+ $channel->item($item);
+ }
+
+ return $feed;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/rssFeed/RssFeed.class.php b/wcfsetup/install/files/lib/system/rssFeed/RssFeed.class.php
new file mode 100644
index 00000000000..871eee124b4
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/rssFeed/RssFeed.class.php
@@ -0,0 +1,61 @@
+
+ * @since 6.1
+ */
+final class RssFeed
+{
+ /**
+ * @var RssFeedChannel[]
+ */
+ private array $channels = [];
+
+ public function channel(RssFeedChannel $channel): static
+ {
+ $this->channels[] = $channel;
+
+ return $this;
+ }
+
+ public function render(): string
+ {
+ $header = <<<'EOT'
+
+
+ EOT;
+
+ $element = new XmlElement(
+ $header,
+ LIBXML_NOERROR | LIBXML_ERR_NONE | LIBXML_ERR_FATAL
+ );
+
+ foreach ($this->channels as $channel) {
+ $toDom = \dom_import_simplexml($element);
+ $fromDom = \dom_import_simplexml($channel->getXML());
+ $toDom->appendChild($toDom->ownerDocument->importNode($fromDom, true));
+ }
+
+ $dom = new \DOMDocument('1.0', 'UTF-8');
+ $dom->appendChild($dom->importNode(\dom_import_simplexml($element), true));
+ $dom->formatOutput = true;
+
+ return $dom->saveXML();
+ }
+
+ public function __toString(): string
+ {
+ return $this->render();
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/rssFeed/RssFeedCategory.class.php b/wcfsetup/install/files/lib/system/rssFeed/RssFeedCategory.class.php
new file mode 100644
index 00000000000..e3f7497410f
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/rssFeed/RssFeedCategory.class.php
@@ -0,0 +1,20 @@
+
+ * @since 6.1
+ */
+final class RssFeedCategory
+{
+ public function __construct(
+ public readonly string $name,
+ public readonly ?string $domain = null,
+ ) {
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/rssFeed/RssFeedChannel.class.php b/wcfsetup/install/files/lib/system/rssFeed/RssFeedChannel.class.php
new file mode 100644
index 00000000000..d5aa375e404
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/rssFeed/RssFeedChannel.class.php
@@ -0,0 +1,196 @@
+
+ * @since 6.1
+ */
+final class RssFeedChannel
+{
+ private string $title;
+ private string $description;
+ private string $link;
+ private string $atomLinkSelf;
+ private string $language;
+ private string $copyright;
+ private string $lastBuildDate;
+ private string $pubDate;
+ private int $ttl = 60;
+
+ /**
+ * @var RssFeedCategory[]
+ */
+ private array $categories = [];
+
+ /**
+ * @var RssFeedItem[]
+ */
+ private array $items = [];
+
+ public function title(string $title): static
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ public function description(string $description): static
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ public function link(string $link): static
+ {
+ $this->link = $link;
+
+ return $this;
+ }
+
+ public function atomLinkSelf(string $link): static
+ {
+ $this->atomLinkSelf = $link;
+
+ return $this;
+ }
+
+ public function language(string $language): static
+ {
+ $this->language = $language;
+
+ return $this;
+ }
+
+ public function copyright(string $copyright): static
+ {
+ $this->copyright = $copyright;
+
+ return $this;
+ }
+
+ public function lastBuildDate(string $date): static
+ {
+ $this->lastBuildDate = $date;
+
+ return $this;
+ }
+
+ public function lastBuildDateFromTimestamp(int $timestamp): static
+ {
+ return $this->lastBuildDate(\gmdate('r', $timestamp));
+ }
+
+ public function pubDate(string $date): static
+ {
+ $this->pubDate = $date;
+
+ return $this;
+ }
+
+ public function pubDateFromTimestamp(int $timestamp): static
+ {
+ return $this->pubDate(\gmdate('r', $timestamp));
+ }
+
+ public function ttl(int $ttl): static
+ {
+ $this->ttl = $ttl;
+
+ return $this;
+ }
+
+ public function category(string $name, ?string $domain = null): static
+ {
+ $this->categories[] = new RssFeedCategory($name, $domain);
+
+ return $this;
+ }
+
+ public function item(RssFeedItem $item): static
+ {
+ $this->items[] = $item;
+
+ return $this;
+ }
+
+ public function getXML(): \SimpleXMLElement
+ {
+ $this->integrityCheck();
+
+ $element = new XmlElement(
+ '',
+ LIBXML_NOERROR | LIBXML_ERR_NONE | LIBXML_ERR_FATAL
+ );
+
+ if (isset($this->title)) {
+ $element->addChild('title', $this->title);
+ }
+ if (isset($this->description)) {
+ $element->addChild('description', $this->description);
+ }
+ if (isset($this->link)) {
+ $element->addChild('link', $this->link);
+ }
+ if (isset($this->language)) {
+ $element->addChild('language', $this->language);
+ }
+ if (isset($this->copyright)) {
+ $element->addChild('copyright', $this->copyright);
+ }
+ if (isset($this->lastBuildDate)) {
+ $element->addChild('lastBuildDate', $this->lastBuildDate);
+ }
+ if (isset($this->pubDate)) {
+ $element->addChild('pubDate', $this->pubDate);
+ }
+
+ if (isset($this->atomLinkSelf)) {
+ $atomLink = $element->addChild('xmlns:atom:link');
+ $atomLink->addAttribute('href', $this->atomLinkSelf);
+ $atomLink->addAttribute('rel', 'self');
+ $atomLink->addAttribute('type', 'application/rss+xml');
+ }
+
+ $element->addChild('ttl', $this->ttl);
+ $element->addChild('generator', 'WoltLab Suite' . (SHOW_VERSION_NUMBER ? ' ' . \WCF_VERSION : ''));
+
+ foreach ($this->categories as $category) {
+ $categoryElement = $element->addChild('category', $category->name);
+ if ($category->domain !== null) {
+ $categoryElement->addAttribute('domain', $category->domain);
+ }
+ }
+
+ foreach ($this->items as $item) {
+ $toDom = \dom_import_simplexml($element);
+ $fromDom = \dom_import_simplexml($item->getXML());
+ $toDom->appendChild($toDom->ownerDocument->importNode($fromDom, true));
+ }
+
+ return $element;
+ }
+
+ private function integrityCheck(): void
+ {
+ // Title, description and link are required.
+ if (!isset($this->title)) {
+ throw new BadMethodCallException("missing parameter 'title'");
+ }
+
+ if (!isset($this->description)) {
+ throw new BadMethodCallException("missing parameter 'description'");
+ }
+
+ if (!isset($this->link)) {
+ throw new BadMethodCallException("missing parameter 'link'");
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/rssFeed/RssFeedEnclosure.class.php b/wcfsetup/install/files/lib/system/rssFeed/RssFeedEnclosure.class.php
new file mode 100644
index 00000000000..664940772db
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/rssFeed/RssFeedEnclosure.class.php
@@ -0,0 +1,21 @@
+
+ * @since 6.1
+ */
+final class RssFeedEnclosure
+{
+ public function __construct(
+ public readonly string $url,
+ public readonly int $length,
+ public readonly string $type
+ ) {
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/rssFeed/RssFeedItem.class.php b/wcfsetup/install/files/lib/system/rssFeed/RssFeedItem.class.php
new file mode 100644
index 00000000000..202ddfca4e6
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/rssFeed/RssFeedItem.class.php
@@ -0,0 +1,203 @@
+
+ * @since 6.1
+ */
+final class RssFeedItem
+{
+ private string $title;
+ private string $link;
+ private string $description;
+ private string $author;
+ private string $comments;
+ private int $slashComments;
+ private RssFeedEnclosure $enclosure;
+ private string $guid;
+ private bool $guidIsPermalink = true;
+ private string $pubDate;
+ private string $creator;
+ private string $contentEncoded;
+ private RssFeedSource $source;
+
+ /**
+ * @var RssFeedCategory[]
+ */
+ private array $categories = [];
+
+ public function title(string $title): static
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ public function description(string $description): static
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ public function link(string $link): static
+ {
+ $this->link = $link;
+
+ return $this;
+ }
+
+ public function pubDate(string $pubDate): static
+ {
+ $this->pubDate = $pubDate;
+
+ return $this;
+ }
+
+ public function pubDateFromTimestamp(int $timestamp): static
+ {
+ return $this->pubDate(\gmdate('r', $timestamp));
+ }
+
+ public function creator(string $creator): static
+ {
+ $this->creator = $creator;
+
+ return $this;
+ }
+
+ public function guid(string $guid, bool $isPermalink = true): static
+ {
+ $this->guid = $guid;
+ $this->guidIsPermalink = $isPermalink;
+
+ return $this;
+ }
+
+ public function enclosure(string $url, int $length, string $type): static
+ {
+ $this->enclosure = new RssFeedEnclosure($url, $length, $type);
+
+ return $this;
+ }
+
+ public function contentEncoded(string $content): static
+ {
+ $this->contentEncoded = $content;
+
+ return $this;
+ }
+
+ public function comments(string $url): static
+ {
+ $this->comments = $url;
+
+ return $this;
+ }
+
+ public function slashComments(int $comments): static
+ {
+ $this->slashComments = $comments;
+
+ return $this;
+ }
+
+ public function category(string $name, ?string $domain = null): static
+ {
+ $this->categories[] = new RssFeedCategory($name, $domain);
+
+ return $this;
+ }
+
+ public function author(string $email): static
+ {
+ $this->author = $email;
+
+ return $this;
+ }
+
+ public function source(string $name, string $url): static
+ {
+ $this->source = new RssFeedSource($name, $url);
+
+ return $this;
+ }
+
+ public function getXML(): \SimpleXMLElement
+ {
+ $this->integrityCheck();
+
+ $element = new XmlElement(
+ ' ',
+ LIBXML_NOERROR | LIBXML_ERR_NONE | LIBXML_ERR_FATAL
+ );
+
+ if (isset($this->title)) {
+ $element->addChild('title', $this->title);
+ }
+ if (isset($this->link)) {
+ $element->addChild('link', $this->link);
+ }
+ if (isset($this->author)) {
+ $element->addChild('author', $this->author);
+ }
+ if (isset($this->description)) {
+ $element->addChildCData('description', $this->description);
+ }
+ if (isset($this->comments)) {
+ $element->addChild('comments', $this->comments);
+ }
+ if (isset($this->slashComments)) {
+ $element->addChild('xmlns:slash:comments', $this->slashComments);
+ }
+ if (isset($this->guid)) {
+ $guidElement = $element->addChild('guid', $this->guid);
+ if (!$this->guidIsPermalink) {
+ $guidElement->addAttribute('isPermaLink', 'false');
+ }
+ }
+ if (isset($this->pubDate)) {
+ $element->addChild('pubDate', $this->pubDate);
+ }
+ if (isset($this->creator)) {
+ $element->addChild('xmlns:dc:creator', $this->creator);
+ }
+ if (isset($this->contentEncoded)) {
+ $element->addChildCData('xmlns:content:encoded', $this->contentEncoded);
+ }
+ if (isset($this->source)) {
+ $sourceElement = $element->addChild('source', $this->source->name);
+ $sourceElement->addAttribute('url', $this->source->url);
+ }
+ if (isset($this->enclosure)) {
+ $enclosureElement = $element->addChild('enclosure');
+ $enclosureElement->addAttribute('url', $this->enclosure->url);
+ $enclosureElement->addAttribute('type', $this->enclosure->type);
+ $enclosureElement->addAttribute('length', $this->enclosure->length);
+ }
+
+ foreach ($this->categories as $category) {
+ $categoryElement = $element->addChild('category', $category->name);
+ if ($category->domain !== null) {
+ $categoryElement->addAttribute('domain', $category->domain);
+ }
+ }
+
+ return $element;
+ }
+
+ private function integrityCheck(): void
+ {
+ // All elements of an item are optional, however at least one of title or description must be present.
+ if (!isset($this->title) && !isset($this->description)) {
+ throw new BadMethodCallException("feed item needs either a 'title' or 'description'");
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/rssFeed/RssFeedSource.class.php b/wcfsetup/install/files/lib/system/rssFeed/RssFeedSource.class.php
new file mode 100644
index 00000000000..9d06ac440d5
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/rssFeed/RssFeedSource.class.php
@@ -0,0 +1,20 @@
+
+ * @since 6.1
+ */
+final class RssFeedSource
+{
+ public function __construct(
+ public readonly string $name,
+ public readonly string $url,
+ ) {
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/rssFeed/XmlElement.class.php b/wcfsetup/install/files/lib/system/rssFeed/XmlElement.class.php
new file mode 100644
index 00000000000..72fc0ff5b7e
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/rssFeed/XmlElement.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.1
+ */
+final class XmlElement extends \SimpleXMLElement
+{
+ public function addChild(string $name, ?string $value = null, ?string $namespace = null): ?static
+ {
+ if ($value !== null && \is_string($value)) {
+ $value = \str_replace('&', '&', $value);
+ }
+
+ return parent::addChild($name, $value, $namespace);
+ }
+
+ public function addChildCData(string $name, string $value): static
+ {
+ $child = $this->addChild($name);
+ $child->addCData($value);
+
+ return $child;
+ }
+
+ private function addCData(string $value): void
+ {
+ $node = \dom_import_simplexml($this);
+ $no = $node->ownerDocument;
+ $node->appendChild($no->createCDATASection($value));
+ }
+}