Files

231 lines
7.1 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\NuqloudMasterCalendar\DAV;
use OCA\DAV\CalDAV\Integration\ExternalCalendar;
use OCA\DAV\CalDAV\Plugin;
use OCA\NuqloudMasterCalendar\AppInfo\Application;
use OCA\NuqloudMasterCalendar\Calendar\SourceCalendarService;
use OCP\IL10N;
use Sabre\CalDAV\CalendarQueryValidator;
use Sabre\CalDAV\ICalendarObject;
use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\PropPatch;
use Sabre\VObject\Component;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
class MasterCalendar extends ExternalCalendar {
public const URI = 'nuqloud-master-calendar';
public function __construct(
private string $principalUri,
private string $mode,
private SourceCalendarService $sourceCalendars,
private IL10N $l,
) {
parent::__construct(Application::APP_ID, self::URI);
}
public function getOwner(): string {
return $this->principalUri;
}
public function getGroup(): ?string {
return null;
}
public function getACL(): array {
return [[
'privilege' => '{DAV:}read',
'principal' => $this->principalUri,
'protected' => true,
], [
'privilege' => '{DAV:}write-properties',
'principal' => $this->principalUri,
'protected' => true,
]];
}
public function setACL(array $acl): void {
throw new Forbidden('Setting ACL is not supported on this calendar');
}
public function getSupportedPrivilegeSet(): ?array {
return null;
}
public function getLastModified(): ?int {
return null;
}
public function delete(): void {
throw new Forbidden('Deleting this calendar is not supported');
}
public function createFile($name, $data = null): void {
throw new Forbidden('Creating events in this calendar is not supported');
}
public function getProperties($properties): array {
return [
'{DAV:}displayname' => $this->l->t('NuQloud Master Calendar'),
'{http://owncloud.org/ns}calendar-enabled' => false,
'{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VEVENT', 'VTODO', 'VJOURNAL']),
'{http://sabredav.org/ns}read-only' => true,
];
}
public function propPatch(PropPatch $propPatch): void {
}
public function childExists($name): bool {
try {
$this->getChild($name);
return true;
} catch (NotFound) {
return false;
}
}
public function getChild($name): MasterCalendarObject {
if (!is_string($name) || $name === '' || str_contains($name, '/')) {
throw new NotFound('Calendar object not found');
}
$components = array_values(array_filter(
$this->getComponents(),
static fn (Component $component): bool => isset($component->{'X-FILENAME'})
&& (string)$component->{'X-FILENAME'} === $name,
));
if ($components === []) {
throw new NotFound('Calendar object not found');
}
return new MasterCalendarObject($this, new VCalendar($components));
}
/** @return MasterCalendarObject[] */
public function getChildren(): array {
$groups = [];
foreach ($this->getComponents() as $component) {
$uid = (string)$component->UID;
$groups[$uid][] = $component;
}
return array_map(fn (array $components): MasterCalendarObject => new MasterCalendarObject($this, new VCalendar($components)), $groups);
}
public function calendarQuery(array $filters): array {
$result = [];
$validator = new CalendarQueryValidator();
foreach ($this->getChildren() as $object) {
$calendarData = Reader::read($object->get());
if ($validator->validate($calendarData, $filters)) {
$result[] = $object->getName();
}
$calendarData->destroy();
}
return $result;
}
/** @return Component[] */
private function getComponents(string $pattern = '', array $searchProperties = []): array {
$components = [];
$identifier = str_ends_with($pattern, '.ics') ? substr($pattern, 0, -4) : $pattern;
$decoded = $this->decodeMasterIdentifier($identifier);
foreach ($this->sourceCalendars->getCalendars($this->principalUri, $this->mode) as $calendar) {
if ($decoded !== null && $decoded['source'] !== $this->sourceCalendars->identifier($calendar)) {
continue;
}
$sourcePattern = $decoded['uid'] ?? $pattern;
$sourceProperties = $decoded === null ? $searchProperties : ['UID'];
foreach ($calendar->search($sourcePattern, $sourceProperties) as $result) {
$name = strtoupper((string)($result['type'] ?? 'VEVENT'));
if (!in_array($name, ['VEVENT', 'VTODO', 'VJOURNAL'], true)) {
continue;
}
foreach (($result['objects'] ?? [$result]) as $object) {
if (!is_array($object) || !isset($object['UID'][0])) {
continue;
}
$sourceUid = (string)$object['UID'][0];
$uid = $this->encodeMasterIdentifier($this->sourceCalendars->identifier($calendar), $sourceUid);
$object['X-NUQLOUD-SOURCE-UID'] = [$sourceUid, []];
$object['UID'] = [$uid, []];
$object['X-FILENAME'] = [$uid . '.ics', []];
$component = $this->toComponent($name, $object);
if ($component->select('UID') !== []) {
$components[] = $component;
}
}
}
}
return $components;
}
/** @param array<string, mixed> $data */
private function toComponent(string $name, array $data): Component {
$calendar = new VCalendar();
$component = $calendar->add($name);
$this->addData($component, $data);
return $component;
}
/** @param array<string, mixed> $data */
private function addData(Component $component, array $data): void {
foreach ($data as $name => $value) {
if ($this->isPropertyTuple($value)) {
$component->add($name, $value[0], $value[1]);
} elseif (is_array($value) && $this->isPropertyTuple($value[0] ?? null)) {
foreach ($value as $property) {
$component->add($name, $property[0], $property[1]);
}
} elseif (is_array($value)) {
foreach ($value as $childData) {
if (is_array($childData)) {
$child = $component->add($name);
$this->addData($child, $childData);
}
}
}
}
}
private function isPropertyTuple(mixed $value): bool {
return is_array($value) && array_key_exists(0, $value) && array_key_exists(1, $value)
&& !is_array($value[0]) && is_array($value[1]);
}
private function encodeMasterIdentifier(string $source, string $uid): string {
return 'nuqloud-master:' . $this->base64UrlEncode($source) . ':' . $this->base64UrlEncode($uid);
}
/** @return array{source: string, uid: string}|null */
private function decodeMasterIdentifier(string $identifier): ?array {
if (!str_starts_with($identifier, 'nuqloud-master:')) {
return null;
}
$parts = explode(':', $identifier, 3);
if (count($parts) !== 3) {
return null;
}
$source = $this->base64UrlDecode($parts[1]);
$uid = $this->base64UrlDecode($parts[2]);
return $source === null || $uid === null ? null : ['source' => $source, 'uid' => $uid];
}
private function base64UrlEncode(string $value): string {
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private function base64UrlDecode(string $value): ?string {
$padding = strlen($value) % 4;
$decoded = base64_decode(strtr($value . str_repeat('=', $padding === 0 ? 0 : 4 - $padding), '-_', '+/'), true);
return $decoded === false ? null : $decoded;
}
}