diff --git a/config/routes.php b/config/routes.php index baf39be8..9bf9090b 100644 --- a/config/routes.php +++ b/config/routes.php @@ -98,6 +98,7 @@ $route->get('/api[/{resource:.+}]', 'ApiController@index'); // Feeds $route->get('/atom', 'FeedController@atom'); +$route->get('/ical', 'FeedController@ical'); $route->get('/rss', 'FeedController@rss'); // Design diff --git a/includes/controller/shifts_controller.php b/includes/controller/shifts_controller.php index 442b9327..f776a581 100644 --- a/includes/controller/shifts_controller.php +++ b/includes/controller/shifts_controller.php @@ -9,7 +9,6 @@ use Engelsystem\Models\Shifts\Shift; use Engelsystem\Models\Shifts\ShiftType; use Engelsystem\Models\Shifts\ShiftSignupStatus; use Engelsystem\ShiftSignupState; -use Illuminate\Support\Collection; /** * @param array|Shift $shift @@ -382,7 +381,7 @@ function shift_next_controller() /** * Export filtered shifts via JSON. - * (Like iCal Export or shifts view) + * (Like shifts view) */ function shifts_json_export_controller() { @@ -396,7 +395,7 @@ function shifts_json_export_controller() throw new HttpForbidden('{"error":"Not allowed"}', ['content-type' => 'application/json']); } - $shifts = load_ical_shifts(); + $shifts = Shifts_by_user(auth()->user()->id); $shifts->sortBy('start_date'); $timeZone = CarbonTimeZone::create(config('timezone')); @@ -460,13 +459,3 @@ function shifts_json_export_controller() header('Content-Type: application/json; charset=utf-8'); raw_output(json_encode($shiftsData)); } - -/** - * Returns users shifts to export. - * - * @return Shift[]|Collection - */ -function load_ical_shifts() -{ - return Shifts_by_user(auth()->user()->id); -} diff --git a/includes/pages/user_ical.php b/includes/pages/user_ical.php deleted file mode 100644 index dbb44672..00000000 --- a/includes/pages/user_ical.php +++ /dev/null @@ -1,67 +0,0 @@ -userFromApi(); - - if (!$user) { - throw new HttpForbidden('Missing or invalid ?key=', ['content-type' => 'text/text']); - } - - if (!auth()->can('ical')) { - throw new HttpForbidden('Not allowed', ['content-type' => 'text/text']); - } - - $ical_shifts = load_ical_shifts(); - - send_ical_from_shifts($ical_shifts); -} - -/** - * Renders an ical calendar from given shifts array. - * - * @param Shift[]|Collection $shifts Shift - */ -function send_ical_from_shifts($shifts) -{ - header('Content-Type: text/calendar; charset=utf-8'); - header('Content-Disposition: attachment; filename=shifts.ics'); - $output = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//-//" . config('app_name') . "//DE\r\nCALSCALE:GREGORIAN\r\n"; - foreach ($shifts as $shift) { - $output .= make_ical_entry_from_shift($shift); - } - $output .= "END:VCALENDAR\r\n"; - $output = trim($output, "\x0A"); - header('Content-Length: ' . strlen($output)); - raw_output($output); -} - -/** - * Renders an ical vevent from given shift. - * - * @param Shift $shift - * @return string - */ -function make_ical_entry_from_shift(Shift $shift) -{ - $output = "BEGIN:VEVENT\r\n"; - $output .= 'UID:' . md5($shift->start->timestamp . $shift->end->timestamp . $shift->shiftType->name) . "\r\n"; - $output .= 'SUMMARY:' . str_replace("\n", "\\n", $shift->shiftType->name) - . ' (' . str_replace("\n", "\\n", $shift->title) . ")\r\n"; - if (isset($shift->user_comment)) { - $output .= 'DESCRIPTION:' . str_replace("\n", "\\n", $shift->user_comment) . "\r\n"; - } - $output .= 'DTSTAMP:' . $shift->start->utc()->format('Ymd\THis\Z') . "\r\n"; - $output .= 'DTSTART:' . $shift->start->utc()->format('Ymd\THis\Z') . "\r\n"; - $output .= 'DTEND:' . $shift->end->utc()->format('Ymd\THis\Z') . "\r\n"; - $output .= 'LOCATION:' . $shift->room->name . "\r\n"; - $output .= "END:VEVENT\r\n"; - return $output; -} diff --git a/resources/views/api/ical.twig b/resources/views/api/ical.twig new file mode 100644 index 00000000..3b0d20a1 --- /dev/null +++ b/resources/views/api/ical.twig @@ -0,0 +1,26 @@ +{% set dateFormat = 'Ymd\\THis\\Z' %} +{% set replacement = {'\n': '\\n'} %} +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//-/{{ config('app_name') }}//DE +CALSCALE:GREGORIAN +{% for entry in shiftEntries %} +BEGIN:VEVENT +UID:{{ uuidBy(entry.id, '54117') }} +DTSTAMP:{{ entry.shift.start.utc().format(dateFormat) }} +DTSTART:{{ entry.shift.start.utc().format(dateFormat) }} +DTEND:{{ entry.shift.end.utc().format(dateFormat) }} +STATUS:CONFIRMED +TRANSP:OPAQUE +SUMMARY:{{ entry.shift.shiftType.name ~ ' (' ~ entry.shift.title ~ ')' | replace(replacement) | raw }} +LOCATION:{{ entry.shift.room.name | replace(replacement) | raw }} +DESCRIPTION:{{ + entry.shift.shiftType.description + ~ '\\n' ~ entry.shift.description + ~ '\\n' ~ entry.user_comment + | replace(replacement) | raw +}} +URL:{{ url('/shifts', {'action': 'view', 'shift_id': entry.shift.id}) | raw }} +END:VEVENT +{% endfor %} +END:VCALENDAR diff --git a/src/Controllers/FeedController.php b/src/Controllers/FeedController.php index b7bb4049..0bd55732 100644 --- a/src/Controllers/FeedController.php +++ b/src/Controllers/FeedController.php @@ -2,6 +2,7 @@ namespace Engelsystem\Controllers; +use Engelsystem\Helpers\Authenticator; use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Engelsystem\Models\News; @@ -13,9 +14,11 @@ class FeedController extends BaseController protected array $permissions = [ 'atom' => 'atom', 'rss' => 'atom', + 'ical' => 'ical', ]; public function __construct( + protected Authenticator $auth, protected Request $request, protected Response $response, ) { @@ -39,6 +42,16 @@ class FeedController extends BaseController ->withView('api/rss', ['news' => $news]); } + public function ical(): Response + { + $shifts = $this->getShifts(); + + return $this->response + ->withHeader('content-type', 'text/calendar; charset=utf-8') + ->withHeader('content-disposition', 'attachment; filename=shifts.ics') + ->withView('api/ical', ['shiftEntries' => $shifts]); + } + protected function getNews(): Collection { $news = $this->request->has('meetings') @@ -50,4 +63,14 @@ class FeedController extends BaseController return $news->get(); } + + protected function getShifts(): Collection + { + return $this->auth->userFromApi() + ->shiftEntries() + ->leftJoin('shifts', 'shifts.id', 'shift_entries.shift_id') + ->orderBy('shifts.start') + ->with(['shift', 'shift.room', 'shift.shiftType']) + ->get(); + } } diff --git a/src/Middleware/LegacyMiddleware.php b/src/Middleware/LegacyMiddleware.php index f36eeab0..46793479 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', - 'ical', 'public_dashboard', 'rooms', 'shift_entries', @@ -78,10 +77,6 @@ class LegacyMiddleware implements MiddlewareInterface protected function loadPage(string $page): array { switch ($page) { - case 'ical': - require_once realpath(__DIR__ . '/../../includes/pages/user_ical.php'); - user_ical(); - break; case 'shifts_json_export': require_once realpath(__DIR__ . '/../../includes/controller/shifts_controller.php'); shifts_json_export_controller(); diff --git a/tests/Unit/Controllers/FeedControllerTest.php b/tests/Unit/Controllers/FeedControllerTest.php index 778c2ebb..c8266924 100644 --- a/tests/Unit/Controllers/FeedControllerTest.php +++ b/tests/Unit/Controllers/FeedControllerTest.php @@ -3,19 +3,27 @@ namespace Engelsystem\Test\Unit\Controllers; use Engelsystem\Controllers\FeedController; +use Engelsystem\Helpers\Authenticator; use Engelsystem\Helpers\Carbon; use Engelsystem\Models\News; +use Engelsystem\Models\Shifts\ShiftEntry; +use Engelsystem\Models\User\User; use Illuminate\Support\Collection; +use PHPUnit\Framework\MockObject\MockObject; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; class FeedControllerTest extends ControllerTest { + protected Authenticator|MockObject $auth; + /** * @covers \Engelsystem\Controllers\FeedController::__construct * @covers \Engelsystem\Controllers\FeedController::atom */ public function testAtom(): void { - $controller = new FeedController($this->request, $this->response); + $controller = new FeedController($this->auth, $this->request, $this->response); $this->setExpects( $this->response, @@ -40,7 +48,7 @@ class FeedControllerTest extends ControllerTest */ public function testRss(): void { - $controller = new FeedController($this->request, $this->response); + $controller = new FeedController($this->auth, $this->request, $this->response); $this->setExpects( $this->response, @@ -59,6 +67,50 @@ class FeedControllerTest extends ControllerTest $controller->rss(); } + /** + * @covers \Engelsystem\Controllers\FeedController::ical + * @covers \Engelsystem\Controllers\FeedController::getShifts + */ + public function testIcal(): void + { + $this->request = $this->request->withQueryParams(['key' => 'fo0']); + $this->auth = new Authenticator( + $this->request, + new Session(new MockArraySessionStorage()), + new User(), + ); + $controller = new FeedController($this->auth, $this->request, $this->response); + + /** @var User $user */ + $user = User::factory()->create(['api_key' => 'fo0']); + ShiftEntry::factory(3)->create(['user_id' => $user->id]); + + $this->response->expects($this->exactly(2)) + ->method('withHeader') + ->withConsecutive( + ['content-type', 'text/calendar; charset=utf-8'], + ['content-disposition', 'attachment; filename=shifts.ics'] + ) + ->willReturn($this->response); + + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function ($view, $data) { + $this->assertEquals('api/ical', $view); + $this->assertArrayHasKey('shiftEntries', $data); + + /** @var ShiftEntry[]|Collection $shiftEntries */ + $shiftEntries = $data['shiftEntries']; + $this->assertCount(3, $shiftEntries); + + $this->assertTrue($shiftEntries[0]->shift->start < $shiftEntries[1]->shift->start); + + return $this->response; + }); + $controller->ical(); + } + + public function getNewsMeetingsDataProvider(): array { return [ @@ -73,7 +125,7 @@ class FeedControllerTest extends ControllerTest */ public function testGetNewsMeetings(bool $isMeeting): void { - $controller = new FeedController($this->request, $this->response); + $controller = new FeedController($this->auth, $this->request, $this->response); $this->request->attributes->set('meetings', $isMeeting); $this->setExpects($this->response, 'withHeader', null, $this->response); @@ -100,7 +152,7 @@ class FeedControllerTest extends ControllerTest public function testGetNewsLimit(): void { News::query()->where('id', '<>', 1)->update(['updated_at' => Carbon::now()->subHour()]); - $controller = new FeedController($this->request, $this->response); + $controller = new FeedController($this->auth, $this->request, $this->response); $this->setExpects($this->response, 'withHeader', null, $this->response); $this->response->expects($this->once()) @@ -125,6 +177,7 @@ class FeedControllerTest extends ControllerTest parent::setUp(); $this->config->set('display_news', 10); + $this->auth = $this->createMock(Authenticator::class); News::factory(7)->create(['is_meeting' => false]); News::factory(5)->create(['is_meeting' => true]);