Skip to content
Merged
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ echo $link->ics([

It has to be a `DateTimeInterface`, so a date string throws an `InvalidLink` rather than being quietly ignored. The value is written as a UTC date-time, for all-day events too, since RFC 5545 does not allow a bare date here.

`URL` and `RRULE` are written into the file as they are given, so they must not contain a carriage return or a line feed. `TRANSP`, `CLASS` and `X-MICROSOFT-CDO-BUSYSTATUS` take one of the tokens listed above in any case, and are written upper-cased. `CLASS` deliberately accepts only those three, not the `x-name` and `iana-token` values RFC 5545 also permits, since validating that grammar costs more than it buys. A value that breaks either rule throws a `Spatie\CalendarLinks\Exceptions\InvalidLink`, so route user supplied data through the event's own fields (the title argument of `Link::create()` and `Link::createAllDay()`, `description()` and `address()`), which are escaped for you, rather than through these options.

A second argument controls presentation rather than content:

```php
Expand Down
29 changes: 29 additions & 0 deletions src/Exceptions/InvalidLink.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,33 @@ public static function invalidDateTimeOption(string $option, mixed $given): self
{
return new self("The `{$option}` option must be a DateTimeInterface, `".get_debug_type($given).'` given.');
}

/** @param mixed $given The value the caller passed, used only to name its type in the message. */
public static function invalidStringOption(string $option, mixed $given): self
{
return new self("The `{$option}` option must be a string, an integer or a Stringable, `".get_debug_type($given).'` given.');
}

/**
* The value is deliberately left out of the message: it contains a line break, which would spread
* the exception message over several lines of a log just as it would over several lines of a calendar.
*/
public static function lineBreakInIcsProperty(string $property): self
{
return new self("ICS property (`{$property}`) must not contain a CR or an LF character. Its value is written to the calendar as is, so a line break would inject additional properties into it.");
}

/**
* CR and LF are dropped from the reported value for the same reason lineBreakInIcsProperty() leaves
* its value out: it would spread a forged line over several lines of a log.
*
* @param non-empty-list<string> $allowedValues
*/
public static function unsupportedIcsPropertyValue(string $property, string $value, array $allowedValues): self
{
$value = str_replace(["\r", "\n"], '', $value);
$allowed = implode('`, `', $allowedValues);

return new self("ICS property (`{$property}`) value (`{$value}`) is invalid. Pass one of `{$allowed}`.");
}
}
153 changes: 145 additions & 8 deletions src/Generators/Ics.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,27 @@ class Ics implements Generator
*/
private const int MAX_CONTENT_LINE_OCTETS = 75;

/** @psalm-var IcsOptions */
/**
* Values here have been through the constructor's guards, so the four properties written verbatim
* carry no line break and the enumerated ones carry a known token. That invariant only covers what
* the constructor was given: a subclass that assigns to this property afterwards is responsible for
* whatever it puts in, since nothing revalidates it before generate() writes it to the file.
*
* @psalm-var IcsOptions
*/
protected array $options = [];

/** @psalm-var IcsPresentationOptions */
protected array $presentationOptions = [];

/**
* Option values are validated here rather than in generate(), so that a value the calendar cannot
* represent is rejected where it enters the library and the stack trace points at the caller that
* supplied it, instead of at whatever renders the link later on.
*
* @param IcsOptions $options Optional ICS properties and components
* @param IcsPresentationOptions $presentationOptions
* @throws InvalidLink
* @throws InvalidLink When an option value cannot be written to the calendar.
*/
public function __construct(array $options = [], array $presentationOptions = [])
{
Expand All @@ -57,21 +68,145 @@ public function __construct(array $options = [], array $presentationOptions = []
throw InvalidLink::invalidDateTimeOption('DTSTAMP', $options['DTSTAMP']);
}

$options = $this->guardAgainstUnwritableValues($options);
$options = $this->guardAgainstUnsupportedTokens($options);

$this->options = $options;
$this->presentationOptions = $presentationOptions;
}

/**
* A URL is a URI and a RRULE is a RECUR, so neither can go through the TEXT escaping of
* escapeString(): they are written to the calendar as they are given. A line break in one would end
* the property and start another, letting a caller-supplied value inject arbitrary content into the
* file, and a lenient parser accepts a bare LF as a line ending, so CR and LF are both rejected.
*
* UID and PRODID are TEXT values, which generate() escapes like any other, so a line break in them
* becomes the \n escape and cannot start a line. They are only stringified here.
*
* @param IcsOptions $options
* @return IcsOptions The options, with each checked value replaced by the string that was checked.
* @throws InvalidLink
* @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.1
*/
private function guardAgainstUnwritableValues(array $options): array
{
foreach (['UID', 'PRODID', 'URL', 'RRULE'] as $property) {
if (! isset($options[$property])) {
continue;
}

$value = $this->asWritten($property, $options[$property]);

if (in_array($property, ['URL', 'RRULE'], true) && strpbrk($value, "\r\n") !== false) {
throw InvalidLink::lineBreakInIcsProperty($property);
}

$options[$property] = $value;
}

return $options;
}

/**
* TRANSP, CLASS and the Microsoft busy status take an enumerated token rather than TEXT, and the
* token is written to the calendar as is. The Psalm types document the allowed tokens, but nothing
* enforces them once the value comes from outside a static analyser's reach.
*
* @param IcsOptions $options
* @return IcsOptions The options, with each checked value replaced by the token that was checked.
* @throws InvalidLink
*/
private function guardAgainstUnsupportedTokens(array $options): array
{
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.2.7
if (isset($options['TRANSP'])) {
$options['TRANSP'] = $this->allowedToken('TRANSP', $options['TRANSP'], ['OPAQUE', 'TRANSPARENT']);
}

// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.1.3
if (isset($options['CLASS'])) {
$options['CLASS'] = $this->allowedToken('CLASS', $options['CLASS'], ['PUBLIC', 'PRIVATE', 'CONFIDENTIAL']);
}

// @see https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcical/
if (isset($options['X-MICROSOFT-CDO-BUSYSTATUS'])) {
$options['X-MICROSOFT-CDO-BUSYSTATUS'] = $this->allowedToken(
'X-MICROSOFT-CDO-BUSYSTATUS',
$options['X-MICROSOFT-CDO-BUSYSTATUS'],
['FREE', 'TENTATIVE', 'BUSY', 'OOF']
);
}

return $options;
}

/**
* @template TToken of string
* @param mixed $value
* @param non-empty-list<TToken> $allowedTokens
* @return TToken
* @throws InvalidLink
*/
private function allowedToken(string $property, mixed $value, array $allowedTokens): string
{
$token = $this->asWritten($property, $value);

// An enumerated property value is case-insensitive, so a lowercase token is as valid as any.
// The upper-cased spelling is the one written to the file, whatever the caller passed.
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.1
$normalized = strtoupper($token);

foreach ($allowedTokens as $allowedToken) {
if ($normalized === $allowedToken) {
return $allowedToken;
}
}

throw InvalidLink::unsupportedIcsPropertyValue($property, $token, $allowedTokens);
}

/**
* The string the file will actually receive. generate() builds its content lines by concatenation,
* which stringifies whatever it is given: an integer, or an object with a __toString(). Checking
* the value as it was passed would therefore miss a Stringable that hands over a line break or an
* unknown token, so the guards check this instead, and keep the result. Calling __toString() once
* and storing what it returned also stops a mutable object from answering differently the second
* time, when generate() would otherwise call it again.
*
* A value with no faithful string form is refused rather than cast, so that the same mistake fails
* the same way whatever was passed. Casting an array yields the word `Array` and a PHP warning, and
* casting an object without a __toString() raises a raw PHP Error, neither of which tells the caller
* what this library expected. Floats and bools are left out on purpose as well: a float's string
* form follows the `precision` ini setting and INF and NAN come out as words, while `false` casts to
* an empty string.
*
* @param mixed $value
* @throws InvalidLink
*/
private function asWritten(string $property, mixed $value): string
{
if (is_string($value) || is_int($value) || $value instanceof \Stringable) {
return (string) $value;
}

throw InvalidLink::invalidStringOption($property, $value);
}

/** @inheritDoc */
#[\Override]
public function generate(Link $link): string
{
$url = [
'BEGIN:VCALENDAR',
'VERSION:2.0', // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.7.4
'PRODID:'.($this->options['PRODID'] ?? 'Spatie calendar-links'), // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.7.3
// PRODID and UID are both TEXT values, so they are escaped like SUMMARY and DESCRIPTION.
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.7.3
'PRODID:'.$this->escapeString($this->options['PRODID'] ?? 'Spatie calendar-links'),
...$this->additionalCalendarProperties($link),
'BEGIN:VEVENT',
'UID:'.($this->options['UID'] ?? $this->generateEventUid($link)),
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.4.7
'UID:'.$this->escapeString($this->options['UID'] ?? $this->generateEventUid($link)),
'SUMMARY:'.$this->escapeString($link->title),
];

Expand Down Expand Up @@ -118,7 +253,7 @@ public function generate(Link $link): string
}

// TRANSP, CLASS and the Microsoft busy status all take an enumerated token rather than TEXT,
// so they are emitted verbatim as well.
// so they are emitted verbatim. The constructor has already checked them against their token lists.
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.2.7
if (isset($this->options['TRANSP'])) {
$url[] = 'TRANSP:'.$this->options['TRANSP'];
Expand Down Expand Up @@ -308,10 +443,12 @@ protected function additionalEventProperties(Link $link): array
*/
protected function generateAlertComponent(Link $link): array
{
// A VALARM DESCRIPTION is a TEXT value, so a custom one needs the same escaping as the default.
// @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.6.6
$description = $this->options['REMINDER']['DESCRIPTION'] ?? null;
if (! is_string($description)) {
$description = 'Reminder: '.$this->escapeString($link->title);
}
$description = is_string($description)
? $this->escapeString($description)
: 'Reminder: '.$this->escapeString($link->title);

$trigger = 'TRIGGER:-PT15M';
if (($reminderTime = $this->options['REMINDER']['TIME'] ?? null) instanceof \DateTimeInterface) {
Expand Down
1 change: 1 addition & 0 deletions src/Link.php
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ public function google(array $urlParameters = []): string
* @psalm-param IcsOptions $options ICS specific properties and components
* @psalm-param IcsPresentationOptions $presentationOptions
* @return string
* @throws \Spatie\CalendarLinks\Exceptions\InvalidLink When an option value cannot be written to the calendar.
*/
public function ics(array $options = [], array $presentationOptions = []): string
{
Expand Down
Loading