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]); + } +}