diff --git a/config/config.default.php b/config/config.default.php
index 62bfcdbd..cefb441e 100644
--- a/config/config.default.php
+++ b/config/config.default.php
@@ -216,7 +216,7 @@ return [
// Must be one of news, meetings, user_shifts, angeltypes, questions
'home_site' => env('HOME_SITE', 'news'),
- // Number of News shown on one site
+ // Number of News shown on one site and for feed readers
'display_news' => env('DISPLAY_NEWS', 10),
// Users are able to sign up
diff --git a/config/routes.php b/config/routes.php
index 75a4965b..baf39be8 100644
--- a/config/routes.php
+++ b/config/routes.php
@@ -96,6 +96,10 @@ $route->addGroup(
// API
$route->get('/api[/{resource:.+}]', 'ApiController@index');
+// Feeds
+$route->get('/atom', 'FeedController@atom');
+$route->get('/rss', 'FeedController@rss');
+
// Design
$route->get('/design', 'DesignController@index');
diff --git a/includes/pages/user_atom.php b/includes/pages/user_atom.php
deleted file mode 100644
index a4926d99..00000000
--- a/includes/pages/user_atom.php
+++ /dev/null
@@ -1,80 +0,0 @@
-userFromApi();
-
- if (!$user) {
- throw new HttpForbidden('Missing or invalid ?key=', ['content-type' => 'text/text']);
- }
-
- if (!auth()->can('atom')) {
- throw new HttpForbidden('Not allowed', ['content-type' => 'text/text']);
- }
-
- $news = $request->has('meetings') ? News::whereIsMeeting((bool) $request->get('meetings', false)) : News::query();
- $news
- ->limit((int) config('display_news'))
- ->orderByDesc('updated_at');
- $output = make_atom_entries_from_news($news->get());
-
- header('Content-Type: application/atom+xml; charset=utf-8');
- header('Content-Length: ' . strlen($output));
- raw_output($output);
-}
-
-/**
- * @param News[]|Collection|SupportCollection $news_entries
- * @return string
- */
-function make_atom_entries_from_news($news_entries)
-{
- $request = app('request');
- $updatedAt = isset($news_entries[0]) ? $news_entries[0]->updated_at->format('Y-m-d\TH:i:sP') : '0000:00:00T00:00:00+00:00';
-
- $html = '
-
-' . config('app_name') . '
-' . $request->getHttpHost()
- . htmlspecialchars(preg_replace(
- '#[&?]key=[a-f\d]+#',
- '',
- $request->getRequestUri()
- ))
- . '
-' . $updatedAt . '' . "\n";
- foreach ($news_entries as $news_entry) {
- $html .= make_atom_entry_from_news($news_entry);
- }
- $html .= '';
- return $html;
-}
-
-/**
- * @param News $news
- * @return string
- */
-function make_atom_entry_from_news(News $news)
-{
- return '
-
- ' . htmlspecialchars($news->title) . '
-
- ' . preg_replace(
- '#^https?://#',
- '',
- page_link_to('news/' . $news->id)
- ) . '
- ' . $news->updated_at->format('Y-m-d\TH:i:sP') . '
- ' . htmlspecialchars($news->text) . '
-' . "\n";
-}
diff --git a/includes/pages/user_myshifts.php b/includes/pages/user_myshifts.php
index 68397f28..7c908f2e 100644
--- a/includes/pages/user_myshifts.php
+++ b/includes/pages/user_myshifts.php
@@ -41,7 +41,7 @@ function user_myshifts()
}
return page_with_title(__('Reset API key'), [
error(
- __('If you reset the key, the url to your iCal- and JSON-export and your atom feed changes! You have to update it in every application using one of these exports.'),
+ __('If you reset the key, the url to your iCal- and JSON-export and your atom/rss feed changes! You have to update it in every application using one of these exports.'),
true
),
button(page_link_to('user_myshifts', ['reset' => 'ack']), __('Continue'), 'btn-danger')
diff --git a/resources/lang/de_DE/default.po b/resources/lang/de_DE/default.po
index e24ae915..6b8cc749 100644
--- a/resources/lang/de_DE/default.po
+++ b/resources/lang/de_DE/default.po
@@ -1733,12 +1733,12 @@ msgstr "API-Key zurücksetzen"
#: includes/pages/user_myshifts.php:44
msgid ""
-"If you reset the key, the url to your iCal- and JSON-export and your atom "
+"If you reset the key, the url to your iCal- and JSON-export and your atom/rss "
"feed changes! You have to update it in every application using one of these "
"exports."
msgstr ""
"Wenn du den API-Key zurücksetzt, ändert sich die URL zu deinem iCal-, JSON-"
-"Export und Atom Feed! Du musst diesen überall ändern, wo er in Benutzung ist."
+"Export und Atom/RSS Feed! Du musst diesen überall ändern, wo er in Benutzung ist."
#: includes/pages/user_myshifts.php:47
msgid "Continue"
diff --git a/resources/lang/pt_BR/default.po b/resources/lang/pt_BR/default.po
index 2cf11581..8ba5dcd7 100644
--- a/resources/lang/pt_BR/default.po
+++ b/resources/lang/pt_BR/default.po
@@ -1500,13 +1500,13 @@ msgstr "Resetar a chave API"
#: includes/pages/user_myshifts.php:27
msgid ""
-"If you reset the key, the url to your iCal- and JSON-export and your atom "
+"If you reset the key, the url to your iCal- and JSON-export and your atom/rss "
"feed changes! You have to update it in every application using one of these "
"exports."
msgstr ""
"Se você reconfigurar a chave, as urls para seu iCal, a exportação em formato "
"JSON \n"
-"e seu feed atom será alterada! Você precisará atualizá-las em todas as "
+"e seu feed atom/rss será alterada! Você precisará atualizá-las em todas as "
"aplicações que estiverem \n"
"usando uma delas."
diff --git a/resources/views/api/atom.twig b/resources/views/api/atom.twig
new file mode 100644
index 00000000..2fb42e79
--- /dev/null
+++ b/resources/views/api/atom.twig
@@ -0,0 +1,28 @@
+{% set dateFormat = 'Y-m-d\\TH:i:sP' %}
+
+
+
+ {{ url('/') }}
+ {{ config('app_name') }}
+ {% if not news.isEmpty() %}{{ news[0].updated_at.format(dateFormat) }}{% else %}0000-00-00T00:00:00+00:00{% endif %}
+
+
+ {% for news in news %}
+
+
+ {% if news.is_meeting %}{{ __('news.is_meeting') }} {% endif %}{{ news.title }}
+ urn:uuid:{{ uuidBy(news.id, '113115') }}
+
+ {{ news.updated_at.format(dateFormat) }}
+ {{ news.created_at.format(dateFormat) }}
+
+ {{ news.user.name }}
+
+
+ {{ news.text }}
+
+
+
+ {% endfor %}
+
+
diff --git a/resources/views/api/rss.twig b/resources/views/api/rss.twig
new file mode 100644
index 00000000..a2b95daf
--- /dev/null
+++ b/resources/views/api/rss.twig
@@ -0,0 +1,28 @@
+{% set dateFormat = 'D, d M Y H:i:s O' %}
+
+
+
+
+ {{ config('app_name') }}
+ {{ url('/') }}
+
+
+ {% if not news.isEmpty() %}{{ news[0].updated_at.format(dateFormat) }}{% else %}Fri, 31 Dec 00 00:00:00 +0000{% endif %}
+
+ {% for news in news %}
+
+ -
+ {% if news.is_meeting %}{{ __('news.is_meeting') }} {% endif %}{{ news.title }}
+ {{ url('/news/' ~ news.id) }}
+ {{ uuidBy(news.id, '113115') }}
+ {{ news.created_at.format(dateFormat) }}
+ {{ news.user.name }}
+
+ {{ news.text }}
+
+
+
+ {% endfor %}
+
+
+
diff --git a/resources/views/layouts/app.twig b/resources/views/layouts/app.twig
index 5b78c841..eeda4d4c 100644
--- a/resources/views/layouts/app.twig
+++ b/resources/views/layouts/app.twig
@@ -18,7 +18,8 @@
{% set parameters = {'meetings': 1}|merge(parameters) -%}
{% endif %}
- {% endif %}
+
+ {%- endif %}
{% endblock %}
diff --git a/src/Controllers/FeedController.php b/src/Controllers/FeedController.php
new file mode 100644
index 00000000..b7bb4049
--- /dev/null
+++ b/src/Controllers/FeedController.php
@@ -0,0 +1,53 @@
+ */
+ protected array $permissions = [
+ 'atom' => 'atom',
+ 'rss' => 'atom',
+ ];
+
+ public function __construct(
+ protected Request $request,
+ protected Response $response,
+ ) {
+ }
+
+ public function atom(): Response
+ {
+ $news = $this->getNews();
+
+ return $this->response
+ ->withHeader('content-type', 'application/atom+xml; charset=utf-8')
+ ->withView('api/atom', ['news' => $news]);
+ }
+
+ public function rss(): Response
+ {
+ $news = $this->getNews();
+
+ return $this->response
+ ->withHeader('content-type', 'application/rss+xml; charset=utf-8')
+ ->withView('api/rss', ['news' => $news]);
+ }
+
+ protected function getNews(): Collection
+ {
+ $news = $this->request->has('meetings')
+ ? News::whereIsMeeting((bool) $this->request->get('meetings', false))
+ : News::query();
+ $news
+ ->limit((int) config('display_news'))
+ ->orderByDesc('updated_at');
+
+ return $news->get();
+ }
+}
diff --git a/src/Middleware/LegacyMiddleware.php b/src/Middleware/LegacyMiddleware.php
index 598fe1ad..f36eeab0 100644
--- a/src/Middleware/LegacyMiddleware.php
+++ b/src/Middleware/LegacyMiddleware.php
@@ -17,7 +17,6 @@ class LegacyMiddleware implements MiddlewareInterface
protected array $free_pages = [
'admin_event_config',
'angeltypes',
- 'atom',
'ical',
'public_dashboard',
'rooms',
@@ -83,10 +82,6 @@ class LegacyMiddleware implements MiddlewareInterface
require_once realpath(__DIR__ . '/../../includes/pages/user_ical.php');
user_ical();
break;
- case 'atom':
- require_once realpath(__DIR__ . '/../../includes/pages/user_atom.php');
- user_atom();
- break;
case 'shifts_json_export':
require_once realpath(__DIR__ . '/../../includes/controller/shifts_controller.php');
shifts_json_export_controller();
diff --git a/src/Middleware/SessionHandlerServiceProvider.php b/src/Middleware/SessionHandlerServiceProvider.php
index 2fa141f4..2679ebc4 100644
--- a/src/Middleware/SessionHandlerServiceProvider.php
+++ b/src/Middleware/SessionHandlerServiceProvider.php
@@ -15,6 +15,7 @@ class SessionHandlerServiceProvider extends ServiceProvider
return [
'/api',
'/atom',
+ '/rss',
'/health',
'/ical',
'/metrics',
diff --git a/tests/Unit/Controllers/FeedControllerTest.php b/tests/Unit/Controllers/FeedControllerTest.php
new file mode 100644
index 00000000..778c2ebb
--- /dev/null
+++ b/tests/Unit/Controllers/FeedControllerTest.php
@@ -0,0 +1,132 @@
+request, $this->response);
+
+ $this->setExpects(
+ $this->response,
+ 'withHeader',
+ ['content-type', 'application/atom+xml; charset=utf-8'],
+ $this->response
+ );
+ $this->response->expects($this->once())
+ ->method('withView')
+ ->willReturnCallback(function ($view, $data) {
+ $this->assertEquals('api/atom', $view);
+ $this->assertArrayHasKey('news', $data);
+
+ return $this->response;
+ });
+
+ $controller->atom();
+ }
+
+ /**
+ * @covers \Engelsystem\Controllers\FeedController::rss
+ */
+ public function testRss(): void
+ {
+ $controller = new FeedController($this->request, $this->response);
+
+ $this->setExpects(
+ $this->response,
+ 'withHeader',
+ ['content-type', 'application/rss+xml; charset=utf-8'],
+ $this->response
+ );
+ $this->response->expects($this->once())
+ ->method('withView')
+ ->willReturnCallback(function ($view, $data) {
+ $this->assertEquals('api/rss', $view);
+ $this->assertArrayHasKey('news', $data);
+
+ return $this->response;
+ });
+ $controller->rss();
+ }
+
+ public function getNewsMeetingsDataProvider(): array
+ {
+ return [
+ [true],
+ [false],
+ ];
+ }
+
+ /**
+ * @covers \Engelsystem\Controllers\FeedController::getNews
+ * @dataProvider getNewsMeetingsDataProvider
+ */
+ public function testGetNewsMeetings(bool $isMeeting): void
+ {
+ $controller = new FeedController($this->request, $this->response);
+
+ $this->request->attributes->set('meetings', $isMeeting);
+ $this->setExpects($this->response, 'withHeader', null, $this->response);
+ $this->response->expects($this->once())
+ ->method('withView')
+ ->willReturnCallback(function ($view, $data) use ($isMeeting) {
+ /** @var Collection|News[] $newsList */
+ $newsList = $data['news'];
+ $this->assertCount($isMeeting ? 5 : 7, $newsList);
+
+ foreach ($newsList as $news) {
+ $this->assertEquals($isMeeting, $news->is_meeting);
+ }
+
+ return $this->response;
+ });
+
+ $controller->rss();
+ }
+
+ /**
+ * @covers \Engelsystem\Controllers\FeedController::getNews
+ */
+ public function testGetNewsLimit(): void
+ {
+ News::query()->where('id', '<>', 1)->update(['updated_at' => Carbon::now()->subHour()]);
+ $controller = new FeedController($this->request, $this->response);
+
+ $this->setExpects($this->response, 'withHeader', null, $this->response);
+ $this->response->expects($this->once())
+ ->method('withView')
+ ->willReturnCallback(function ($view, $data) {
+ $this->assertCount(10, $data['news']);
+
+ /** @var News $news1 */
+ $news1 = $data['news'][0];
+ /** @var News $news2 */
+ $news2 = $data['news'][1];
+ $this->assertTrue($news1->updated_at > $news2->updated_at, 'First news must be up to date');
+
+ return $this->response;
+ });
+
+ $controller->rss();
+ }
+
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->config->set('display_news', 10);
+
+ News::factory(7)->create(['is_meeting' => false]);
+ News::factory(5)->create(['is_meeting' => true]);
+ }
+}