Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions _define.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions _routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
14 changes: 14 additions & 0 deletions js/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
});
2 changes: 1 addition & 1 deletion lib/GaletteEvents/Booking.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/**
Expand Down
117 changes: 116 additions & 1 deletion lib/GaletteEvents/Controllers/Crud/EventsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
*/
Expand Down Expand Up @@ -139,19 +145,128 @@ 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,
$this->getTemplate('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
*/
Expand Down
2 changes: 1 addition & 1 deletion lib/GaletteEvents/Event.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
104 changes: 104 additions & 0 deletions lib/GaletteEvents/Feeds.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

/**
* This file is part of Galette Events plugin (https://galette.eu).
* SPDX-FileCopyrightText: Copyright © 2018-2026 The Galette Team
* SPDX-License-Identifier: GPL-3.0-or-later
*/

declare(strict_types=1);

namespace GaletteEvents;

use Galette\Core\Db;
use Galette\Entity\Adherent;

/**
* Personal calendar subscriptions
*
* Each member may get a secret token, that gives read access to the events
* they can see, without session. Tokens are stored as is, so that members
* can display their subscription URL again; they are only created on demand.
*
* @author Johan Cwiklinski <johan@x-tnd.be>
*/
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;
}
}
10 changes: 9 additions & 1 deletion lib/GaletteEvents/Ics.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,22 @@ public function __construct(
* Build calendar
*
* @param array<Event> $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();
Expand Down
Loading
Loading