Reimplemented news Atom feed, added RSS feed

This commit is contained in:
Igor Scheller 2023-01-28 17:08:15 +01:00 committed by Michael Weimann
parent 8223193330
commit 3d0d5067fd
13 changed files with 254 additions and 92 deletions

View File

@ -216,7 +216,7 @@ return [
// Must be one of news, meetings, user_shifts, angeltypes, questions // Must be one of news, meetings, user_shifts, angeltypes, questions
'home_site' => env('HOME_SITE', 'news'), '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), 'display_news' => env('DISPLAY_NEWS', 10),
// Users are able to sign up // Users are able to sign up

View File

@ -96,6 +96,10 @@ $route->addGroup(
// API // API
$route->get('/api[/{resource:.+}]', 'ApiController@index'); $route->get('/api[/{resource:.+}]', 'ApiController@index');
// Feeds
$route->get('/atom', 'FeedController@atom');
$route->get('/rss', 'FeedController@rss');
// Design // Design
$route->get('/design', 'DesignController@index'); $route->get('/design', 'DesignController@index');

View File

@ -1,80 +0,0 @@
<?php
use Engelsystem\Http\Exceptions\HttpForbidden;
use Engelsystem\Models\News;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as SupportCollection;
/**
* Publically available page to feed the news to feed readers
*/
function user_atom()
{
$request = request();
$user = auth()->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 = '<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>' . config('app_name') . '</title>
<id>' . $request->getHttpHost()
. htmlspecialchars(preg_replace(
'#[&?]key=[a-f\d]+#',
'',
$request->getRequestUri()
))
. '</id>
<updated>' . $updatedAt . '</updated>' . "\n";
foreach ($news_entries as $news_entry) {
$html .= make_atom_entry_from_news($news_entry);
}
$html .= '</feed>';
return $html;
}
/**
* @param News $news
* @return string
*/
function make_atom_entry_from_news(News $news)
{
return '
<entry>
<title>' . htmlspecialchars($news->title) . '</title>
<link href="' . page_link_to('news/' . $news->id) . '"/>
<id>' . preg_replace(
'#^https?://#',
'',
page_link_to('news/' . $news->id)
) . '</id>
<updated>' . $news->updated_at->format('Y-m-d\TH:i:sP') . '</updated>
<summary type="html">' . htmlspecialchars($news->text) . '</summary>
</entry>' . "\n";
}

View File

@ -41,7 +41,7 @@ function user_myshifts()
} }
return page_with_title(__('Reset API key'), [ return page_with_title(__('Reset API key'), [
error( 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 true
), ),
button(page_link_to('user_myshifts', ['reset' => 'ack']), __('Continue'), 'btn-danger') button(page_link_to('user_myshifts', ['reset' => 'ack']), __('Continue'), 'btn-danger')

View File

@ -1733,12 +1733,12 @@ msgstr "API-Key zurücksetzen"
#: includes/pages/user_myshifts.php:44 #: includes/pages/user_myshifts.php:44
msgid "" 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 " "feed changes! You have to update it in every application using one of these "
"exports." "exports."
msgstr "" msgstr ""
"Wenn du den API-Key zurücksetzt, ändert sich die URL zu deinem iCal-, JSON-" "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 #: includes/pages/user_myshifts.php:47
msgid "Continue" msgid "Continue"

View File

@ -1500,13 +1500,13 @@ msgstr "Resetar a chave API"
#: includes/pages/user_myshifts.php:27 #: includes/pages/user_myshifts.php:27
msgid "" 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 " "feed changes! You have to update it in every application using one of these "
"exports." "exports."
msgstr "" msgstr ""
"Se você reconfigurar a chave, as urls para seu iCal, a exportação em formato " "Se você reconfigurar a chave, as urls para seu iCal, a exportação em formato "
"JSON \n" "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" "aplicações que estiverem \n"
"usando uma delas." "usando uma delas."

View File

@ -0,0 +1,28 @@
{% set dateFormat = 'Y-m-d\\TH:i:sP' %}
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>{{ url('/') }}</id>
<title>{{ config('app_name') }}</title>
<updated>{% if not news.isEmpty() %}{{ news[0].updated_at.format(dateFormat) }}{% else %}0000-00-00T00:00:00+00:00{% endif %}</updated>
<link rel="self" type="application/atom+xml" href="{{ url('/atom', {'key': request.get('key') }) }}"/>
{% for news in news %}
<entry>
<title>{% if news.is_meeting %}{{ __('news.is_meeting') }} {% endif %}{{ news.title }}</title>
<id>urn:uuid:{{ uuidBy(news.id, '113115') }}</id>
<link href="{{ url('/news/' ~ news.id) }}"/>
<updated>{{ news.updated_at.format(dateFormat) }}</updated>
<published>{{ news.created_at.format(dateFormat) }}</published>
<author>
<name>{{ news.user.name }}</name>
</author>
<content type="html">
{{ news.text }}
</content>
</entry>
{% endfor %}
</feed>

View File

@ -0,0 +1,28 @@
{% set dateFormat = 'D, d M Y H:i:s O' %}
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ config('app_name') }}</title>
<link>{{ url('/') }}</link>
<description></description>
<atom:link href="{{ url('/atom', {'key': request.get('key') }) }}" rel="self" type="application/rss+xml"/>
<lastBuildDate>{% if not news.isEmpty() %}{{ news[0].updated_at.format(dateFormat) }}{% else %}Fri, 31 Dec 00 00:00:00 +0000{% endif %}</lastBuildDate>
{% for news in news %}
<item>
<title>{% if news.is_meeting %}{{ __('news.is_meeting') }} {% endif %}{{ news.title }}</title>
<link>{{ url('/news/' ~ news.id) }}</link>
<guid isPermaLink="false">{{ uuidBy(news.id, '113115') }}</guid>
<pubDate>{{ news.created_at.format(dateFormat) }}</pubDate>
<author>{{ news.user.name }}</author>
<description>
{{ news.text }}
</description>
</item>
{% endfor %}
</channel>
</rss>

View File

@ -18,7 +18,8 @@
{% set parameters = {'meetings': 1}|merge(parameters) -%} {% set parameters = {'meetings': 1}|merge(parameters) -%}
{% endif %} {% endif %}
<link href="{{ url('atom', parameters) }}" type="application/atom+xml" rel="alternate" title="Atom Feed"> <link href="{{ url('atom', parameters) }}" type="application/atom+xml" rel="alternate" title="Atom Feed">
{% endif %} <link href="{{ url('rss', parameters) }}" type="application/rss+xml" rel="alternate" title="RSS Feed" />
{%- endif %}
{% endblock %} {% endblock %}
</head> </head>

View File

@ -0,0 +1,53 @@
<?php
namespace Engelsystem\Controllers;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Models\News;
use Illuminate\Support\Collection;
class FeedController extends BaseController
{
/** @var array<string, string> */
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();
}
}

View File

@ -17,7 +17,6 @@ class LegacyMiddleware implements MiddlewareInterface
protected array $free_pages = [ protected array $free_pages = [
'admin_event_config', 'admin_event_config',
'angeltypes', 'angeltypes',
'atom',
'ical', 'ical',
'public_dashboard', 'public_dashboard',
'rooms', 'rooms',
@ -83,10 +82,6 @@ class LegacyMiddleware implements MiddlewareInterface
require_once realpath(__DIR__ . '/../../includes/pages/user_ical.php'); require_once realpath(__DIR__ . '/../../includes/pages/user_ical.php');
user_ical(); user_ical();
break; break;
case 'atom':
require_once realpath(__DIR__ . '/../../includes/pages/user_atom.php');
user_atom();
break;
case 'shifts_json_export': case 'shifts_json_export':
require_once realpath(__DIR__ . '/../../includes/controller/shifts_controller.php'); require_once realpath(__DIR__ . '/../../includes/controller/shifts_controller.php');
shifts_json_export_controller(); shifts_json_export_controller();

View File

@ -15,6 +15,7 @@ class SessionHandlerServiceProvider extends ServiceProvider
return [ return [
'/api', '/api',
'/atom', '/atom',
'/rss',
'/health', '/health',
'/ical', '/ical',
'/metrics', '/metrics',

View File

@ -0,0 +1,132 @@
<?php
namespace Engelsystem\Test\Unit\Controllers;
use Engelsystem\Controllers\FeedController;
use Engelsystem\Helpers\Carbon;
use Engelsystem\Models\News;
use Illuminate\Support\Collection;
class FeedControllerTest extends ControllerTest
{
/**
* @covers \Engelsystem\Controllers\FeedController::__construct
* @covers \Engelsystem\Controllers\FeedController::atom
*/
public function testAtom(): void
{
$controller = new FeedController($this->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]);
}
}