diff --git a/_define.php b/_define.php index d4f20f2..a7913f2 100644 --- a/_define.php +++ b/_define.php @@ -47,6 +47,8 @@ 'event_bookings_export' => 'groupmanager', 'events_bookings_export' => 'groupmanager', 'events_calendar' => 'member', + 'events_feed_subscribe' => 'member', + 'events_feed_regenerate' => 'member', 'ajax-events_calendar' => 'member' ], dbver: 1.2 diff --git a/_routes.php b/_routes.php index 7d049c9..d589613 100644 --- a/_routes.php +++ b/_routes.php @@ -174,6 +174,22 @@ [EventsController::class, 'calendar'] )->setName('events_calendar')->add(Authenticate::class); +$app->post( + '/events/feed', + [EventsController::class, 'subscribe'] +)->setName('events_feed_subscribe')->add(Authenticate::class); + +$app->post( + '/events/feed/regenerate', + [EventsController::class, 'regenerateFeed'] +)->setName('events_feed_regenerate')->add(Authenticate::class); + +//calendar applications have no session: the secret token is the only credential +$app->get( + '/events/feed/{token:' . GaletteEvents\Feeds::TOKEN_PATTERN . '}.ics', + [EventsController::class, 'feed'] +)->setName('events_feed'); + $app->get( '/ajax/events/calendar', [EventsController::class, 'ajaxCalendar'] diff --git a/js/calendar.js b/js/calendar.js index e448a85..4306885 100644 --- a/js/calendar.js +++ b/js/calendar.js @@ -61,4 +61,18 @@ $(function() { }); calendar.render(); + + var $feed = $('#calendar_feed'); + $('#calendar_subscribe').on('click', function() { + $feed.modal('show'); + }); + $('#feed_regenerate').on('submit', function(event) { + if (!window.confirm($(this).data('confirm'))) { + event.preventDefault(); + } + }); + //back from getting or changing the subscription address + if (options.show_subscription) { + $feed.modal('show'); + } }); diff --git a/lib/GaletteEvents/Booking.php b/lib/GaletteEvents/Booking.php index 5d3f227..bf227d4 100644 --- a/lib/GaletteEvents/Booking.php +++ b/lib/GaletteEvents/Booking.php @@ -635,7 +635,7 @@ private function canBook(Event $event): bool return true; } - return $event->isOpen() && Events::isVisible($event->getGroup(), $this->login); + return $event->isOpen() && Events::isVisible($event->getGroup(), Viewer::fromLogin($this->login)); } /** diff --git a/lib/GaletteEvents/Controllers/Crud/EventsController.php b/lib/GaletteEvents/Controllers/Crud/EventsController.php index d3aa7fa..9ebd997 100644 --- a/lib/GaletteEvents/Controllers/Crud/EventsController.php +++ b/lib/GaletteEvents/Controllers/Crud/EventsController.php @@ -14,8 +14,11 @@ use Galette\Repository\Groups; use Galette\Core\Pagination; use GaletteEvents\Filters\EventsList; +use Galette\Entity\Adherent; use GaletteEvents\Event; +use GaletteEvents\Feeds; use GaletteEvents\Ics; +use GaletteEvents\Viewer; use GaletteEvents\NotFoundException; use GaletteEvents\Repository\Events; use Slim\Psr7\Request; @@ -37,6 +40,9 @@ class EventsController extends AbstractController { + /** Ended events kept in calendar subscriptions */ + public const int FEED_PAST_MONTHS = 12; + /** * Entity name, for session keys and logs */ @@ -139,6 +145,10 @@ public function calendar( ); } + //calendar subscription, for members only + $viewer = Viewer::fromLogin($this->login); + $token = $viewer->id === null ? null : (new Feeds($this->zdb))->getToken($viewer->id); + // display page $this->view->render( $response, @@ -146,12 +156,117 @@ public function calendar( [ 'page_title' => _T("Events calendar", "events"), 'require_dialog' => true, - 'module_id' => $this->getModuleId() + 'module_id' => $this->getModuleId(), + 'can_subscribe' => $viewer->id !== null, + 'feed_urls' => $token === null ? null : $this->getFeedUrls($token), + 'show_subscription' => isset($request->getQueryParams()['subscription']), ] ); return $response; } + /** + * Subscription URLs of a calendar + * + * @param string $token Subscription token + * + * @return array{https: string, webcal: string} + */ + private function getFeedUrls(string $token): array + { + $url = rtrim($this->preferences->getURL(), '/') + . $this->routeparser->urlFor('events_feed', ['token' => $token]); + return [ + 'https' => $url, + 'webcal' => (string)preg_replace('#^https?://#', 'webcal://', $url), + ]; + } + + /** + * Get, or create, the calendar subscription of logged-in member + */ + public function subscribe(Request $request, Response $response): Response + { + return $this->storeFeedToken($response, false); + } + + /** + * Change calendar subscription of logged-in member + */ + public function regenerateFeed(Request $request, Response $response): Response + { + return $this->storeFeedToken($response, true); + } + + /** + * Create or change calendar subscription token of logged-in member + * + * @param bool $regenerate Replace an existing token + */ + private function storeFeedToken(Response $response, bool $regenerate): Response + { + $viewer = Viewer::fromLogin($this->login); + if ($viewer->id === null) { + //superadmin is not a member + return $this->redirectWithErrors( + response: $response, + errors: [_T("You do not have permission for requested URL.")], + redirect_url: $this->routeparser->urlFor('events_calendar') + ); + } + + $feeds = new Feeds($this->zdb); + $successes = []; + if ($regenerate) { + $feeds->regenerate($viewer->id); + $successes[] = _T( + 'Your subscription address has been changed: calendar applications subscribed to the previous one no longer get updates.', + 'events' + ); + } else { + $feeds->getOrCreateToken($viewer->id); + } + + return $this->redirect( + response: $response, + redirect_url: $this->routeparser->urlFor('events_calendar') . '?subscription', + successes: $successes + ); + } + + /** + * Calendar subscription, as fetched by calendar applications, without session + * + * Unknown tokens and inactive members get the same answer. + * + * @param string $token Subscription token + */ + public function feed(Request $request, Response $response, string $token): Response + { + $id = (new Feeds($this->zdb))->getMember($token); + $member = $id === null ? null : new Adherent($this->zdb, $id); + if ($member === null || !$member->isActive()) { + return $response->withStatus(404); + } + + //descriptions are in the member language + $this->i18n->changeLanguage($member->language); + + $events = new Events($this->zdb, $this->login, $this->history, $this->preferences); + $list = $events->getFeed( + Viewer::fromMember($member), + (new \DateTimeImmutable('-' . self::FEED_PAST_MONTHS . ' months'))->format('Y-m-d') + ); + + $ics = new Ics($this->preferences, $this->routeparser); + $name = $this->preferences->pref_nom . ' – ' . _T('Events', 'events'); + $response->getBody()->write($ics->getCalendar($list, $name)->serialize()); + return $response + ->withHeader('Content-Type', Ics::CONTENT_TYPE) + ->withHeader('Content-Disposition', 'inline; filename="events.ics"') + ->withHeader('Cache-Control', 'private, no-cache'); + } + /** * Calendar view */ diff --git a/lib/GaletteEvents/Event.php b/lib/GaletteEvents/Event.php index 3004239..71bef76 100644 --- a/lib/GaletteEvents/Event.php +++ b/lib/GaletteEvents/Event.php @@ -740,7 +740,7 @@ public function canEdit(Login $login): bool */ public function canShow(Login $login): bool { - if (Events::isVisible($this->group, $login)) { + if (Events::isVisible($this->group, Viewer::fromLogin($login))) { return true; } diff --git a/lib/GaletteEvents/Feeds.php b/lib/GaletteEvents/Feeds.php new file mode 100644 index 0000000..7ffc16e --- /dev/null +++ b/lib/GaletteEvents/Feeds.php @@ -0,0 +1,104 @@ + + */ +class Feeds +{ + public const string TABLE = 'feeds'; + public const string TOKEN_PATTERN = '[0-9a-f]{64}'; + + /** + * Constructor + * + * @param Db $zdb Database instance + */ + public function __construct(private readonly Db $zdb) + { + } + + /** + * Get token of a member, if any + * + * @param int $member Member identifier + */ + public function getToken(int $member): ?string + { + $select = $this->zdb->select(EVENTS_PREFIX . self::TABLE); + $select->where([Adherent::PK => $member]); + $row = $this->zdb->execute($select)->current(); + return $row ? (string)$row['token'] : null; + } + + /** + * Get token of a member, create it if missing + * + * @param int $member Member identifier + */ + public function getOrCreateToken(int $member): string + { + return $this->getToken($member) ?? $this->regenerate($member); + } + + /** + * Give a member a new token; the previous one, if any, no longer works + * + * @param int $member Member identifier + */ + public function regenerate(int $member): string + { + $token = bin2hex(random_bytes(32)); + $values = [ + 'token' => $token, + 'creation_date' => date('Y-m-d'), + ]; + + if ($this->getToken($member) === null) { + $insert = $this->zdb->insert(EVENTS_PREFIX . self::TABLE); + $insert->values($values + [Adherent::PK => $member]); + $this->zdb->execute($insert); + } else { + $update = $this->zdb->update(EVENTS_PREFIX . self::TABLE); + $update->set($values)->where([Adherent::PK => $member]); + $this->zdb->execute($update); + } + + return $token; + } + + /** + * Get member a token belongs to + * + * @param string $token Token + */ + public function getMember(string $token): ?int + { + if (!preg_match('/^' . self::TOKEN_PATTERN . '$/', $token)) { + return null; + } + + $select = $this->zdb->select(EVENTS_PREFIX . self::TABLE); + $select->where(['token' => $token]); + $row = $this->zdb->execute($select)->current(); + return $row ? (int)$row[Adherent::PK] : null; + } +} diff --git a/lib/GaletteEvents/Ics.php b/lib/GaletteEvents/Ics.php index 894eee2..d818c56 100644 --- a/lib/GaletteEvents/Ics.php +++ b/lib/GaletteEvents/Ics.php @@ -46,14 +46,22 @@ public function __construct( * Build calendar * * @param array $events Events to include + * @param ?string $name Name of a subscribed calendar, refreshed by calendar applications */ - public function getCalendar(array $events): VCalendar + public function getCalendar(array $events, ?string $name = null): VCalendar { $calendar = new VCalendar([ 'PRODID' => '-//Galette//Galette Events//EN', 'CALSCALE' => 'GREGORIAN', 'METHOD' => 'PUBLISH', ]); + if ($name !== null) { + //RFC 7986 properties, and their widely used predecessors + $calendar->add('NAME', $name); + $calendar->add('X-WR-CALNAME', $name); + $calendar->add('REFRESH-INTERVAL', 'PT1H', ['VALUE' => 'DURATION']); + $calendar->add('X-PUBLISHED-TTL', 'PT1H'); + } $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); $domain = $this->getUidDomain(); diff --git a/lib/GaletteEvents/Repository/Events.php b/lib/GaletteEvents/Repository/Events.php index 4fad43a..9fe19e3 100644 --- a/lib/GaletteEvents/Repository/Events.php +++ b/lib/GaletteEvents/Repository/Events.php @@ -25,6 +25,7 @@ use Galette\Repository\Groups; use GaletteEvents\Event; use GaletteEvents\Filters\EventsList; +use GaletteEvents\Viewer; /** * Events @@ -87,17 +88,11 @@ public function getList(bool $bookable = false, bool $fullcalendar = false, bool $select::JOIN_LEFT ); - if (!$this->login->isAdmin() && !$this->login->isStaff()) { - $managed = array_map('intval', $this->login->managed_groups); - $groups = self::getVisibleGroups($this->login); - - $visible = [new Predicate\IsNull('e.' . Group::PK)]; - if (count($groups)) { - $visible[] = new Predicate\In('e.' . Group::PK, $groups); - } - $visible = new PredicateSet($visible, PredicateSet::OP_OR); - - $booked = new Predicate\Operator('b.' . Adherent::PK, '=', $this->login->id); + $viewer = Viewer::fromLogin($this->login); + if (!$viewer->seesAll()) { + $managed = $viewer->managed; + $visible = $this->getVisiblePredicate($viewer); + $booked = $this->getBookedPredicate($viewer); if ($this->filters->calendar_filter) { $set = [$visible, $booked]; @@ -245,35 +240,101 @@ public function getList(bool $bookable = false, bool $fullcalendar = false, bool } } + /** + * Get events of a calendar subscription + * + * Events are the ones of the calendar: public ones, the ones of the + * viewer groups and the ones they have booked, whatever their state. + * + * @param Viewer $viewer Member the subscription belongs to + * @param string $since Events ended before this date, as Y-m-d, are left out + * + * @return array + */ + public function getFeed(Viewer $viewer, string $since): array + { + $select = $this->zdb->select(EVENTS_PREFIX . Event::TABLE, 'e'); + $select->join( + ['b' => PREFIX_DB . EVENTS_PREFIX . Booking::TABLE], + 'e.' . Event::PK . '=b.' . Event::PK, + [], + $select::JOIN_LEFT + ); + if (!$viewer->seesAll()) { + $select->where(new PredicateSet( + [$this->getVisiblePredicate($viewer), $this->getBookedPredicate($viewer)], + PredicateSet::OP_OR + )); + } + $select->where->greaterThanOrEqualTo('e.end_date', $since); + $select->group(['e.' . Event::PK]); + $select->order(['e.begin_date', 'e.' . Event::PK]); + + $events = []; + foreach ($this->zdb->execute($select) as $row) { + $events[] = new Event($this->zdb, $this->login, $this->history, $row); + } + return $events; + } + /** * Groups whose events a member can see and book: the ones they belong to or manage * * Events without group are visible to every member. * - * @param Login $login Logged-in member + * @param Viewer $viewer Member events are listed for * * @return array */ - public static function getVisibleGroups(Login $login): array + public static function getVisibleGroups(Viewer $viewer): array { + if ($viewer->id === null) { + return $viewer->managed; + } return array_values(array_unique(array_merge( - array_map('intval', Groups::loadGroups((int)$login->id, false, false)), - array_map('intval', $login->managed_groups) + array_map('intval', Groups::loadGroups($viewer->id, false, false)), + $viewer->managed ))); } + /** + * Events visible to a member: public ones, and the ones of their groups + * + * @param Viewer $viewer Viewer + */ + private function getVisiblePredicate(Viewer $viewer): PredicateSet + { + $groups = self::getVisibleGroups($viewer); + $visible = [new Predicate\IsNull('e.' . Group::PK)]; + if (count($groups)) { + $visible[] = new Predicate\In('e.' . Group::PK, $groups); + } + return new PredicateSet($visible, PredicateSet::OP_OR); + } + + /** + * Events a member has booked + * + * @param Viewer $viewer Viewer + */ + private function getBookedPredicate(Viewer $viewer): Predicate\PredicateInterface + { + return $viewer->id === null + ? new Predicate\Expression('1 = 0') + : new Predicate\Operator('b.' . Adherent::PK, '=', $viewer->id); + } + /** * Is an event of this group visible to a member * - * @param ?int $group Event group, if any - * @param Login $login Logged-in member + * @param ?int $group Event group, if any + * @param Viewer $viewer Member events are listed for */ - public static function isVisible(?int $group, Login $login): bool + public static function isVisible(?int $group, Viewer $viewer): bool { - return $login->isAdmin() - || $login->isStaff() + return $viewer->seesAll() || $group === null - || in_array($group, self::getVisibleGroups($login), true); + || in_array($group, self::getVisibleGroups($viewer), true); } /** diff --git a/lib/GaletteEvents/Viewer.php b/lib/GaletteEvents/Viewer.php new file mode 100644 index 0000000..488d90e --- /dev/null +++ b/lib/GaletteEvents/Viewer.php @@ -0,0 +1,86 @@ + + */ +final readonly class Viewer +{ + /** + * Constructor + * + * @param ?int $id Member identifier, none for superadmin + * @param bool $admin Is an administrator + * @param bool $staff Is a staff member + * @param array $managed Managed groups identifiers + */ + public function __construct( + public ?int $id, + public bool $admin, + public bool $staff, + public array $managed + ) { + } + + /** + * Logged-in member + * + * @param Login $login Login instance + */ + public static function fromLogin(Login $login): self + { + //superadmin is not a member, its identifier is 0 + $id = (int)$login->id; + //admins and staff members manage every group de facto + $privileged = $login->isAdmin() || $login->isStaff(); + return new self( + $id > 0 ? $id : null, + $login->isAdmin(), + $login->isStaff(), + $privileged ? [] : array_values(array_map('intval', $login->managed_groups)) + ); + } + + /** + * Any member, as if they were logged in + * + * @param Adherent $member Member + */ + public static function fromMember(Adherent $member): self + { + //admins and staff members manage every group de facto + $privileged = $member->isAdmin() || $member->isStaff(); + return new self( + (int)$member->id, + $member->isAdmin(), + $member->isStaff(), + $privileged ? [] : array_values(array_map('intval', Groups::loadManagedGroups((int)$member->id, as_group: false))) + ); + } + + /** + * Does viewer see every event? + */ + public function seesAll(): bool + { + return $this->admin || $this->staff; + } +} diff --git a/scripts/mysql.sql b/scripts/mysql.sql index f340208..f31d728 100644 --- a/scripts/mysql.sql +++ b/scripts/mysql.sql @@ -98,4 +98,19 @@ CREATE TABLE galette_events_activitiesbookings ( REFERENCES galette_events_bookings (id_booking) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; +-- +-- Table structure for table `galette_events_feeds` +-- + +DROP TABLE IF EXISTS galette_events_feeds; +CREATE TABLE galette_events_feeds ( + id_adh int(10) unsigned NOT NULL, + token char(64) NOT NULL, + creation_date date NOT NULL default '1901-01-01', + PRIMARY KEY (id_adh), + UNIQUE KEY galette_events_feeds_token_key (token), + CONSTRAINT galette_events_feeds_id_adh_fkey FOREIGN KEY (id_adh) + REFERENCES galette_adherents (id_adh) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + SET FOREIGN_KEY_CHECKS=1; diff --git a/scripts/pgsql.sql b/scripts/pgsql.sql index 2fae68d..65e928f 100644 --- a/scripts/pgsql.sql +++ b/scripts/pgsql.sql @@ -109,3 +109,16 @@ CREATE TABLE galette_events_activitiesbookings ( PRIMARY KEY (id_activitybooking), UNIQUE (id_activity, id_booking) ); + +-- +-- Table structure for table `galette_events_feeds` +-- + +DROP TABLE IF EXISTS galette_events_feeds CASCADE; +CREATE TABLE galette_events_feeds ( + id_adh integer NOT NULL REFERENCES galette_adherents (id_adh) ON DELETE CASCADE ON UPDATE CASCADE, + token character(64) NOT NULL, + creation_date date default '19010101' NOT NULL, + PRIMARY KEY (id_adh), + UNIQUE (token) +); diff --git a/scripts/upgrade-to-1.2-mysql.sql b/scripts/upgrade-to-1.2-mysql.sql index 71b81ad..2c7b038 100644 --- a/scripts/upgrade-to-1.2-mysql.sql +++ b/scripts/upgrade-to-1.2-mysql.sql @@ -8,3 +8,14 @@ ALTER TABLE galette_events_events ADD begin_time time default NULL AFTER end_date, ADD end_time time default NULL AFTER begin_time; + +-- Personal calendar subscriptions +CREATE TABLE galette_events_feeds ( + id_adh int(10) unsigned NOT NULL, + token char(64) NOT NULL, + creation_date date NOT NULL default '1901-01-01', + PRIMARY KEY (id_adh), + UNIQUE KEY galette_events_feeds_token_key (token), + CONSTRAINT galette_events_feeds_id_adh_fkey FOREIGN KEY (id_adh) + REFERENCES galette_adherents (id_adh) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; diff --git a/scripts/upgrade-to-1.2-pgsql.sql b/scripts/upgrade-to-1.2-pgsql.sql index f204355..6c520c3 100644 --- a/scripts/upgrade-to-1.2-pgsql.sql +++ b/scripts/upgrade-to-1.2-pgsql.sql @@ -8,3 +8,12 @@ ALTER TABLE galette_events_events ADD COLUMN begin_time time default NULL, ADD COLUMN end_time time default NULL; + +-- Personal calendar subscriptions +CREATE TABLE galette_events_feeds ( + id_adh integer NOT NULL REFERENCES galette_adherents (id_adh) ON DELETE CASCADE ON UPDATE CASCADE, + token character(64) NOT NULL, + creation_date date default '19010101' NOT NULL, + PRIMARY KEY (id_adh), + UNIQUE (token) +); diff --git a/templates/default/calendar.html.twig b/templates/default/calendar.html.twig index 10b5c14..f933f6b 100644 --- a/templates/default/calendar.html.twig +++ b/templates/default/calendar.html.twig @@ -7,6 +7,14 @@ {% extends "page.html.twig" %} {% block content %} + {% if can_subscribe %} +
+ +
+ {% endif %}
+ {% if can_subscribe %} + + {% endif %} {% endblock %} {% block javascripts %} {% set calendar_options = { 'dataurl': url_for("ajax-events_calendar"), 'locale': i18n.getWebID(), + 'show_subscription': show_subscription, 'buttons': { 'listDay': {'text': _T("Daily list", "events")}, 'listWeek': {'text': _T("Weekly list", "events")}, diff --git a/tests/EventsFixtures.php b/tests/EventsFixtures.php index ca83914..c5770c6 100644 --- a/tests/EventsFixtures.php +++ b/tests/EventsFixtures.php @@ -16,6 +16,7 @@ use GaletteEvents\Activity; use GaletteEvents\Booking; use GaletteEvents\Event; +use GaletteEvents\Feeds; /** * Events, bookings and groups for plugin tests @@ -29,7 +30,7 @@ trait EventsFixtures */ protected function cleanEvents(): void { - foreach (['activitiesbookings', 'activitiesevents', Booking::TABLE, Event::TABLE, Activity::TABLE] as $table) { + foreach (['activitiesbookings', 'activitiesevents', Booking::TABLE, Event::TABLE, Activity::TABLE, Feeds::TABLE] as $table) { $this->zdb->execute($this->zdb->delete(EVENTS_PREFIX . $table)); } } diff --git a/tests/GaletteEvents/Controllers/Crud/tests/units/EventsController.php b/tests/GaletteEvents/Controllers/Crud/tests/units/EventsController.php index 4fb97b8..bb832c7 100644 --- a/tests/GaletteEvents/Controllers/Crud/tests/units/EventsController.php +++ b/tests/GaletteEvents/Controllers/Crud/tests/units/EventsController.php @@ -42,6 +42,7 @@ public function setUp(): void public function tearDown(): void { $this->login->logout(); + $this->preferences->pref_galette_url = ''; //iCalendar export generates the instance UUID, core tests expect the one they have, if any $update = $this->zdb->update(\Galette\Core\Preferences::TABLE) ->set(['val_pref' => $this->instance_uuid]) @@ -602,4 +603,131 @@ public function testIcsDownload(): void ); $this->expectLogin($test_response); } + + /** + * Get the calendar subscription of a member, as calendar applications do + * + * @param string $token Subscription token + */ + private function getFeed(string $token): \Psr\Http\Message\ResponseInterface + { + return $this->app->handle($this->createRequest('events_feed', ['token' => $token])); + } + + /** + * Set member active state + * + * @param int $member Member ID + * @param bool $active Active state + */ + private function setActive(int $member, bool $active): void + { + $update = $this->zdb->update(\Galette\Entity\Adherent::TABLE) + ->set(['activite_adh' => $this->zdb->isPostgres() ? ($active ? 'true' : 'false') : (int)$active]) + ->where([\Galette\Entity\Adherent::PK => $member]); + $this->zdb->execute($update); + } + + /** + * Members get, change and use their calendar subscription + */ + public function testSubscription(): void + { + $member_one = $this->getMemberOne(); + $member_two = $this->getMemberTwo(); + $other = $this->createGroup('Other group', [], [$member_two]); + $this->insertEvent('Public event', ['begin_time' => '18:30', 'end_time' => '20:00']); + $this->insertEvent('Other group event', ['id_group' => $other->getId()]); + $feeds = new \GaletteEvents\Feeds($this->zdb); + //instance URL cannot be guessed without request host + $this->preferences->pref_galette_url = 'https://galette.test/'; + + $this->logMember($this->dataAdherentOne()); + $body = (string)$this->app->handle($this->createRequest('events_calendar'))->getBody(); + $this->assertStringContainsString('id="calendar_subscribe"', $body); + $this->assertStringContainsString($this->routeparser->urlFor('events_feed_subscribe'), $body); + //no subscription until asked for + $this->assertNull((new \GaletteEvents\Feeds($this->zdb))->getToken((int)$member_one->id)); + + $test_response = $this->app->handle($this->createRequest('events_feed_subscribe', [], 'POST')); + $this->assertSame( + ['Location' => [$this->routeparser->urlFor('events_calendar') . '?subscription']], + $test_response->getHeaders() + ); + $token = $feeds->getToken((int)$member_one->id); + $this->assertNotNull($token); + + //subscribing again keeps the address + $this->app->handle($this->createRequest('events_feed_subscribe', [], 'POST')); + $this->assertSame($token, $feeds->getToken((int)$member_one->id)); + + $body = (string)$this->app->handle( + $this->createRequest('events_calendar', [], 'GET', 'text/html', ['subscription' => '']) + )->getBody(); + $url = 'galette.test' . $this->routeparser->urlFor('events_feed', ['token' => $token]); + $this->assertStringContainsString('href="webcal://' . $url . '"', $body); + $this->assertStringContainsString('value="https://' . $url . '"', $body); + $this->assertStringContainsString('"show_subscription":true', $body); + $this->login->logout(); + + //calendar applications have no session + $test_response = $this->getFeed($token); + $this->assertSame(200, $test_response->getStatusCode()); + $this->assertSame(['text/calendar; charset=utf-8'], $test_response->getHeader('Content-Type')); + $ics = (string)$test_response->getBody(); + $this->assertStringContainsString('SUMMARY:Public event', $ics); + $this->assertStringNotContainsString('Other group event', $ics); + $this->assertStringContainsString('X-WR-CALNAME:', $ics); + $this->assertStringContainsString('REFRESH-INTERVAL;VALUE=DURATION:PT1H', $ics); + + //unknown token and inactive member get the same answer + $this->assertSame(404, $this->getFeed(str_repeat('a', 64))->getStatusCode()); + $this->setActive((int)$member_one->id, false); + try { + $this->assertSame(404, $this->getFeed($token)->getStatusCode()); + } finally { + $this->setActive((int)$member_one->id, true); + } + + //previous address no longer works once changed + $this->logMember($this->dataAdherentOne()); + $test_response = $this->app->handle($this->createRequest('events_feed_regenerate', [], 'POST')); + $this->assertSame( + ['Location' => [$this->routeparser->urlFor('events_calendar') . '?subscription']], + $test_response->getHeaders() + ); + $this->expectFlashData(['success_detected' => [_T( + 'Your subscription address has been changed: calendar applications subscribed to the previous one no longer get updates.', + 'events' + )]]); + $this->login->logout(); + $new = $feeds->getToken((int)$member_one->id); + $this->assertNotSame($token, $new); + $this->assertSame(404, $this->getFeed($token)->getStatusCode()); + $this->assertSame(200, $this->getFeed((string)$new)->getStatusCode()); + } + + /** + * Superadmin is not a member, it has no subscription + */ + public function testSuperAdminCannotSubscribe(): void + { + $this->logSuperAdmin(); + $body = (string)$this->app->handle($this->createRequest('events_calendar'))->getBody(); + $this->assertStringNotContainsString('id="calendar_subscribe"', $body); + + $test_response = $this->app->handle($this->createRequest('events_feed_subscribe', [], 'POST')); + $this->assertSame(['Location' => [$this->routeparser->urlFor('events_calendar')]], $test_response->getHeaders()); + $this->expectFlashData(['error_detected' => [_T('You do not have permission for requested URL.')]]); + } + + /** + * Visitors cannot subscribe + */ + public function testVisitorCannotSubscribe(): void + { + foreach (['events_feed_subscribe', 'events_feed_regenerate'] as $route) { + $this->expectLogin($this->app->handle($this->createRequest($route, [], 'POST'))); + } + } } diff --git a/tests/GaletteEvents/tests/units/Feeds.php b/tests/GaletteEvents/tests/units/Feeds.php new file mode 100644 index 0000000..a262879 --- /dev/null +++ b/tests/GaletteEvents/tests/units/Feeds.php @@ -0,0 +1,118 @@ + + */ +class Feeds extends GaletteTestCase +{ + use EventsFixtures; + + protected int $seed = 20261027091512; + + /** + * Cleanup after each test method + */ + public function tearDown(): void + { + $this->login->logout(); + $this->cleanEvents(); + parent::tearDown(); + } + + /** + * Tokens are created on demand, kept, and changed on request + */ + public function testTokens(): void + { + $member_one = (int)$this->getMemberOne()->id; + $member_two = (int)$this->getMemberTwo()->id; + $feeds = new \GaletteEvents\Feeds($this->zdb); + + $this->assertNull($feeds->getToken($member_one)); + $token = $feeds->getOrCreateToken($member_one); + $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $token); + $this->assertSame($token, $feeds->getOrCreateToken($member_one)); + $this->assertSame($token, $feeds->getToken($member_one)); + $this->assertSame($member_one, $feeds->getMember($token)); + + $other = $feeds->getOrCreateToken($member_two); + $this->assertNotSame($token, $other); + $this->assertSame($member_two, $feeds->getMember($other)); + + //previous token no longer works + $new = $feeds->regenerate($member_one); + $this->assertNotSame($token, $new); + $this->assertNull($feeds->getMember($token)); + $this->assertSame($member_one, $feeds->getMember($new)); + $this->assertSame($other, $feeds->getToken($member_two)); + + $this->assertNull($feeds->getMember(str_repeat('0', 64))); + $this->assertNull($feeds->getMember(strtoupper($new))); + $this->assertNull($feeds->getMember($new . "' OR '1'='1")); + } + + /** + * Subscription gets the events of the calendar, ended for less than given date + */ + public function testFeedEvents(): void + { + $member_one = $this->getMemberOne(); + $member_two = $this->getMemberTwo(); + $own = $this->createGroup('Own group', [], [$member_one]); + $other = $this->createGroup('Other group', [], [$member_two]); + $managed = $this->createGroup('Managed group', [$member_one]); + $past = [ + 'begin_date' => date('Y-m-d', strtotime('-3 months')), + 'end_date' => date('Y-m-d', strtotime('-3 months')), + ]; + $old = [ + 'begin_date' => date('Y-m-d', strtotime('-14 months')), + 'end_date' => date('Y-m-d', strtotime('-13 months')), + ]; + + $this->insertEvent('public'); + $this->insertEvent('public closed past', ['is_open' => false] + $past); + $this->insertEvent('public too old', $old); + $this->insertEvent('own group', ['id_group' => $own->getId()]); + $this->insertEvent('managed group', ['id_group' => $managed->getId()]); + $this->insertEvent('other group', ['id_group' => $other->getId()]); + $this->insertBooking($this->insertEvent('other group booked', ['id_group' => $other->getId()]), $member_one->id); + + $since = date('Y-m-d', strtotime('-12 months')); + $events = new \GaletteEvents\Repository\Events($this->zdb, $this->login, $this->history, $this->preferences); + $names = function (\GaletteEvents\Viewer $viewer) use ($events, $since): array { + $names = array_map(fn(\GaletteEvents\Event $event): string => $event->getName(), $events->getFeed($viewer, $since)); + sort($names); + return $names; + }; + + //nobody is logged in + $this->assertSame( + ['managed group', 'other group booked', 'own group', 'public', 'public closed past'], + $names(\GaletteEvents\Viewer::fromMember($member_one)) + ); + $this->assertSame( + ['other group', 'other group booked', 'public', 'public closed past'], + $names(\GaletteEvents\Viewer::fromMember($member_two)) + ); + $this->assertSame( + ['managed group', 'other group', 'other group booked', 'own group', 'public', 'public closed past'], + $names(new \GaletteEvents\Viewer((int)$member_two->id, false, true, [])) + ); + } +} diff --git a/tests/GaletteEvents/tests/units/Viewer.php b/tests/GaletteEvents/tests/units/Viewer.php new file mode 100644 index 0000000..9a4a07f --- /dev/null +++ b/tests/GaletteEvents/tests/units/Viewer.php @@ -0,0 +1,77 @@ + + */ +class Viewer extends GaletteTestCase +{ + use EventsFixtures; + + protected int $seed = 20261027081512; + + /** + * Cleanup after each test method + */ + public function tearDown(): void + { + $this->login->logout(); + $this->cleanEvents(); + parent::tearDown(); + } + + /** + * A member without session is seen as when logged in + */ + public function testFromMemberAsLogin(): void + { + $member_one = $this->getMemberOne(); + $member_two = $this->getMemberTwo(); + $managed = $this->createGroup('Managed group', [$member_two], [$member_one]); + $this->createGroup('Other group', [], [$member_two]); + + $this->logMember($this->dataAdherentTwo()); + $viewer = \GaletteEvents\Viewer::fromLogin($this->login); + $this->assertEquals($viewer, \GaletteEvents\Viewer::fromMember(new \Galette\Entity\Adherent($this->zdb, (int)$member_two->id))); + $this->assertSame((int)$member_two->id, $viewer->id); + $this->assertSame([(int)$managed->getId()], $viewer->managed); + $this->assertFalse($viewer->seesAll()); + $this->login->logout(); + + //staff members manage every group de facto, none is listed + $staff = $this->getStaffMember($member_one); + $this->login->logout(); + $this->logMember($this->dataAdherentOne()); + $viewer = \GaletteEvents\Viewer::fromLogin($this->login); + $this->assertEquals($viewer, \GaletteEvents\Viewer::fromMember(new \Galette\Entity\Adherent($this->zdb, (int)$member_one->id))); + $this->assertTrue($viewer->seesAll()); + $this->assertSame([], $viewer->managed); + $this->login->logout(); + $this->resetStaffStatus($staff, $member_two); + } + + /** + * Superadmin is not a member + */ + public function testSuperAdmin(): void + { + $this->logSuperAdmin(); + $viewer = \GaletteEvents\Viewer::fromLogin($this->login); + $this->assertNull($viewer->id); + $this->assertTrue($viewer->seesAll()); + } +}