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
24 changes: 23 additions & 1 deletion Presenters/CalendarSubscriptionPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public function PageLoad(): bool
$rid = null;
$uid = null;
$aid = null;
$user = null;
$resourceIds = [];

$reservations = [];
Expand Down Expand Up @@ -123,14 +124,23 @@ public function PageLoad(): bool
);

$session = ServiceLocator::GetServer()->GetUserSession();
$viewOptions = iCalendarReservationViewOptions::Default();
if ($uid !== null) {
// The full icskey+uid URL is bearer authorization for this user's feed.
// It identifies the subscribed user, but it is not a logged-in session.
$viewOptions = iCalendarReservationViewOptions::ForUserSubscription($this->CreateSubscriptionUserSession($user));
} elseif (!$session->IsLoggedIn()) {
$viewOptions = iCalendarReservationViewOptions::ForAnonymousSubscription();
}

foreach ($res as $r) {
if (empty($resourceIds) || in_array($r->ResourceId, $resourceIds)) {
$reservations[] = new iCalendarReservationView(
$r,
$session,
$this->privacyFilter,
$summaryFormat
$summaryFormat,
$viewOptions
);
}
}
Expand All @@ -139,4 +149,16 @@ public function PageLoad(): bool

return true;
}

private function CreateSubscriptionUserSession(User $user): UserSession
{
$session = new UserSession($user->Id());
$session->FirstName = $user->FirstName();
$session->LastName = $user->LastName();
$session->Email = $user->EmailAddress();
$session->Timezone = $user->Timezone();
$session->PublicId = $user->GetPublicId();

return $session;
}
}
47 changes: 31 additions & 16 deletions lib/Application/Schedule/SlotLabelFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,15 @@ public static function Create(ReservationItemView $reservation)

/**
* @param ReservationItemView $reservation
* @param string $format
* @param string|null $format
* @param bool $skipVisibilityChecks When true, the privacy and permission gates
* that normally suppress the label are not applied. The caller takes full
* responsibility for having verified the viewer may see this reservation
* (e.g. an ICS feed authorized by the user's secret subscription URL).
* User-detail redaction from privacy.hide.user.details still applies.
* @return string
*/
public function Format(ReservationItemView $reservation, $format = null)
public function Format(ReservationItemView $reservation, ?string $format = null, bool $skipVisibilityChecks = false)
{
$shouldHideUser = Configuration::Instance()->GetKey(ConfigKeys::PRIVACY_HIDE_USER_DETAILS, new BooleanConverter());
$shouldHideDetails = ReservationDetailsFilter::HideReservationDetails($reservation->StartDate, $reservation->EndDate);
Expand All @@ -64,16 +69,18 @@ public function Format(ReservationItemView $reservation, $format = null)
$shouldHideDetails = $shouldHideDetails && !$canEditResource && !$canSeeUserDetails;
}

if ($shouldHideDetails) {
return '';
}
if (!$skipVisibilityChecks) {
if ($shouldHideDetails) {
return '';
}

if (!$shouldHideReservations && !$this->user->IsLoggedIn()) {
return '';
}
if (!$shouldHideReservations && !$this->user->IsLoggedIn()) {
return '';
}

if (!in_array($reservation->ResourceId, $this->UserResourcePermissions($this->user->UserId)) && !$reservation->IsUserOwner($this->user->UserId) && !$reservation->IsUserInvited($this->user->UserId) && !$reservation->IsUserParticipating($this->user->UserId)) {
return '';
if (!in_array($reservation->ResourceId, $this->UserResourcePermissions($this->user->UserId)) && !$reservation->IsUserOwner($this->user->UserId) && !$reservation->IsUserInvited($this->user->UserId) && !$reservation->IsUserParticipating($this->user->UserId)) {
return '';
}
}

if (empty($format)) {
Expand All @@ -84,7 +91,12 @@ public function Format(ReservationItemView $reservation, $format = null)
return '';
}

$name = $shouldHideUser ? Resources::GetInstance()->GetString('Private') : $this->GetFullName($reservation);
$privateNotice = Resources::GetInstance()->GetString('Private');
$name = $shouldHideUser ? $privateNotice : $this->GetFullName($reservation);
$email = $shouldHideUser ? $privateNotice : ($reservation->OwnerEmailAddress ?? '');
$organization = $shouldHideUser ? $privateNotice : ($reservation->OwnerOrganization ?? '');
$phone = $shouldHideUser ? $privateNotice : ($reservation->OwnerPhone ?? '');
$position = $shouldHideUser ? $privateNotice : ($reservation->OwnerPosition ?? '');

$timezone = 'UTC';
$dateFormat = Resources::GetInstance()->GetDateFormat('res_popup');
Expand All @@ -95,16 +107,19 @@ public function Format(ReservationItemView $reservation, $format = null)
$label = str_replace('{name}', $name ?? '', $label);
$label = str_replace('{title}', $reservation->Title ?? '', $label);
$label = str_replace('{description}', $reservation->Description ?? '', $label);
$label = str_replace('{email}', $reservation->OwnerEmailAddress, $label);
$label = str_replace('{organization}', $reservation->OwnerOrganization ?? '', $label);
$label = str_replace('{phone}', $reservation->OwnerPhone ?? '', $label);
$label = str_replace('{position}', $reservation->OwnerPosition ?? '', $label);
$label = str_replace('{email}', $email, $label);
$label = str_replace('{organization}', $organization, $label);
$label = str_replace('{phone}', $phone, $label);
$label = str_replace('{position}', $position, $label);
$label = str_replace('{startdate}', $reservation->StartDate->ToTimezone($timezone)->Format($dateFormat), $label);
$label = str_replace('{enddate}', $reservation->EndDate->ToTimezone($timezone)->Format($dateFormat), $label);
$label = str_replace('{resourcename}', implode(', ', $reservation->ResourceNames), $label);
if (!$shouldHideUser) {
$label = str_replace('{participants}', trim(implode(', ', $reservation->ParticipantNames)), $label);
$label = str_replace('{invitees}', trim(implode(', ', $reservation->InviteeNames)), $label);
} else {
$label = str_replace('{participants}', '', $label);
$label = str_replace('{invitees}', '', $label);
}

$matches = [];
Expand Down Expand Up @@ -171,7 +186,7 @@ private function UserResourcePermissions($userId)

class NullSlotLabelFactory extends SlotLabelFactory
{
public function Format(ReservationItemView $reservation, $format = null)
public function Format(ReservationItemView $reservation, ?string $format = null, bool $skipVisibilityChecks = false)
{
return '';
}
Expand Down
103 changes: 87 additions & 16 deletions lib/Application/Schedule/iCalendarReservationView.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,70 @@
<?php

class iCalendarReservationViewOptions
{
private ?bool $canViewUser = null;
private ?bool $canViewDetails = null;
private ?UserSession $formattingUser = null;
private bool $formatWithoutVisibilityChecks = false;
private bool $respectPublicReservationVisibility = true;

public static function Default(): static
{
return new iCalendarReservationViewOptions();
}

public static function ForAnonymousSubscription(): static
{
$options = new iCalendarReservationViewOptions();
$options->formatWithoutVisibilityChecks = true;
return $options;
}

public static function ForUserSubscription(UserSession $subscribedUser): static
{
$options = new iCalendarReservationViewOptions();
$options->canViewUser = true;
$options->canViewDetails = true;
$options->formattingUser = $subscribedUser;
$options->formatWithoutVisibilityChecks = true;
$options->respectPublicReservationVisibility = false;
return $options;
}

public function CanViewUser(UserSession $currentUser, IPrivacyFilter $privacyFilter, ReservationItemView $reservation): bool
{
if ($this->canViewUser !== null) {
return $this->canViewUser;
}

return $privacyFilter->CanViewUser($currentUser, $reservation, $reservation->OwnerId);
}

public function CanViewDetails(UserSession $currentUser, IPrivacyFilter $privacyFilter, ReservationItemView $reservation): bool
{
if ($this->canViewDetails !== null) {
return $this->canViewDetails;
}

return $privacyFilter->CanViewDetails($currentUser, $reservation, $reservation->OwnerId);
}

public function FormattingUser(UserSession $currentUser): UserSession
{
return $this->formattingUser ?? $currentUser;
}

public function FormatWithoutVisibilityChecks(): bool
{
return $this->formatWithoutVisibilityChecks;
}

public function RespectPublicReservationVisibility(): bool
{
return $this->respectPublicReservationVisibility;
}
}

class iCalendarReservationView
{
public $Classification;
Expand Down Expand Up @@ -35,26 +100,23 @@ class iCalendarReservationView
* @param UserSession $currentUser
* @param IPrivacyFilter $privacyFilter
* @param string|null $summaryFormat
* @param iCalendarReservationViewOptions|null $options
*/
public function __construct($res, UserSession $currentUser, IPrivacyFilter $privacyFilter, $summaryFormat = null)
public function __construct($res, UserSession $currentUser, IPrivacyFilter $privacyFilter, $summaryFormat = null, $options = null)
{
if ($summaryFormat == null) {
$summaryFormat = Configuration::Instance()->GetKey(ConfigKeys::RESERVATION_LABELS_ICS_SUMMARY);
}
$factory = new SlotLabelFactory($currentUser);
$options = $options ?? iCalendarReservationViewOptions::Default();
$formattingUser = $options->FormattingUser($currentUser);
$factory = new SlotLabelFactory($formattingUser);
$this->ReservationItemView = $res;
$canViewUser = $privacyFilter->CanViewUser($currentUser, $res, $res->OwnerId);
$canViewDetails = $privacyFilter->CanViewDetails($currentUser, $res, $res->OwnerId);

// PrivacyFilter only gates on privacy.hide.reservation.details and privacy.hide.user.details.
// For anonymous (not logged-in) callers, also enforce privacy.view.reservations, which is the
// site-wide switch that controls whether unauthenticated visitors may see reservation details at all.
if (!$currentUser->IsLoggedIn()) {
$publicViewAllowed = Configuration::Instance()->GetKey(ConfigKeys::PRIVACY_VIEW_RESERVATIONS, new BooleanConverter());
if (!$publicViewAllowed) {
$canViewUser = false;
$canViewDetails = false;
}
$canViewUser = $options->CanViewUser($currentUser, $privacyFilter, $res);
$canViewDetails = $options->CanViewDetails($currentUser, $privacyFilter, $res);

if ($options->RespectPublicReservationVisibility() && !$this->CanViewPublicReservations($currentUser)) {
$canViewUser = false;
$canViewDetails = false;
}

$this->ExportFactory = PluginManager::Instance()->LoadExport();
Expand All @@ -70,7 +132,7 @@ public function __construct($res, UserSession $currentUser, IPrivacyFilter $priv

$this->DateEnd = $res->EndDate;
$this->DateStart = $res->StartDate;
$this->Summary = $canViewDetails ? self::toRfc5545Text($factory->Format($res, $summaryFormat)) : $privateNotice;
$this->Summary = $canViewDetails ? self::toRfc5545Text($factory->Format($res, $summaryFormat, skipVisibilityChecks: $options->FormatWithoutVisibilityChecks())) : $privateNotice;
$this->Description = $canViewDetails ? self::toRfc5545Text($res->Description ?? '') : $privateNotice;
$fullName = new FullName($res->OwnerFirstName, $res->OwnerLastName);
$this->Organizer = $canViewUser ? $fullName->__toString() : $privateNotice;
Expand All @@ -91,13 +153,22 @@ public function __construct($res, UserSession $currentUser, IPrivacyFilter $priv
$this->LastModified = empty($res->ModifiedDate) || $res->ModifiedDate->ToString() == '' ? $this->DateCreated : $res->ModifiedDate;
$this->IsPending = $res->RequiresApproval;

if ($canViewUser && $res->OwnerId == $currentUser->UserId) {
if ($canViewUser && !empty($res->OwnerId) && $res->OwnerId == $currentUser->UserId) {
$this->OrganizerEmail = str_replace('@', '-noreply@', $res->OwnerEmailAddress);
}

$this->ExtraIcalLines = method_exists($this->ExportFactory, 'GetIcalendarExtraLines') ? $this->ExportFactory->GetIcalendarExtraLines($res) : null;
}

private function CanViewPublicReservations(UserSession $currentUser)
{
if ($currentUser->IsLoggedIn()) {
return true;
}

return Configuration::Instance()->GetKey(ConfigKeys::PRIVACY_VIEW_RESERVATIONS, new BooleanConverter());
}

/**
* Escapes a plain-text value for use in an iCalendar TEXT property (RFC 5545 §3.3.11).
* Backslashes, semicolons, and commas are escaped first; then newline sequences
Expand Down
Loading