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 $data */ private function toComponent(string $name, array $data): Component { $calendar = new VCalendar(); $component = $calendar->add($name); $this->addData($component, $data); return $component; } /** @param array $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; } }