diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 310f1ec83be3d..7962b97ce647b 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -130,6 +130,7 @@ 'OCA\\DAV\\CalDAV\\Schedule\\IMipPlugin' => $baseDir . '/../lib/CalDAV/Schedule/IMipPlugin.php', 'OCA\\DAV\\CalDAV\\Schedule\\IMipService' => $baseDir . '/../lib/CalDAV/Schedule/IMipService.php', 'OCA\\DAV\\CalDAV\\Schedule\\Plugin' => $baseDir . '/../lib/CalDAV/Schedule/Plugin.php', + 'OCA\\DAV\\CalDAV\\Search\\CalendarAccountScopedSearchProvider' => $baseDir . '/../lib/CalDAV/Search/CalendarAccountScopedSearchProvider.php', 'OCA\\DAV\\CalDAV\\Search\\SearchPlugin' => $baseDir . '/../lib/CalDAV/Search/SearchPlugin.php', 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\CompFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/CompFilter.php', 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\LimitFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/LimitFilter.php', @@ -186,6 +187,7 @@ 'OCA\\DAV\\CardDAV\\Notification\\Notifier' => $baseDir . '/../lib/CardDAV/Notification/Notifier.php', 'OCA\\DAV\\CardDAV\\PhotoCache' => $baseDir . '/../lib/CardDAV/PhotoCache.php', 'OCA\\DAV\\CardDAV\\Plugin' => $baseDir . '/../lib/CardDAV/Plugin.php', + 'OCA\\DAV\\CardDAV\\Search\\ContactAccountScopedSearchProvider' => $baseDir . '/../lib/CardDAV/Search/ContactAccountScopedSearchProvider.php', 'OCA\\DAV\\CardDAV\\Security\\CardDavRateLimitingPlugin' => $baseDir . '/../lib/CardDAV/Security/CardDavRateLimitingPlugin.php', 'OCA\\DAV\\CardDAV\\Sharing\\Backend' => $baseDir . '/../lib/CardDAV/Sharing/Backend.php', 'OCA\\DAV\\CardDAV\\Sharing\\Service' => $baseDir . '/../lib/CardDAV/Sharing/Service.php', @@ -428,6 +430,7 @@ 'OCA\\DAV\\Search\\ACalendarSearchProvider' => $baseDir . '/../lib/Search/ACalendarSearchProvider.php', 'OCA\\DAV\\Search\\ContactsSearchProvider' => $baseDir . '/../lib/Search/ContactsSearchProvider.php', 'OCA\\DAV\\Search\\EventsSearchProvider' => $baseDir . '/../lib/Search/EventsSearchProvider.php', + 'OCA\\DAV\\Search\\SearchOperatorEvaluator' => $baseDir . '/../lib/Search/SearchOperatorEvaluator.php', 'OCA\\DAV\\Search\\TasksSearchProvider' => $baseDir . '/../lib/Search/TasksSearchProvider.php', 'OCA\\DAV\\Server' => $baseDir . '/../lib/Server.php', 'OCA\\DAV\\ServerFactory' => $baseDir . '/../lib/ServerFactory.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 98a49d46284dd..af50eee89c705 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -145,6 +145,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\Schedule\\IMipPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/Schedule/IMipPlugin.php', 'OCA\\DAV\\CalDAV\\Schedule\\IMipService' => __DIR__ . '/..' . '/../lib/CalDAV/Schedule/IMipService.php', 'OCA\\DAV\\CalDAV\\Schedule\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/Schedule/Plugin.php', + 'OCA\\DAV\\CalDAV\\Search\\CalendarAccountScopedSearchProvider' => __DIR__ . '/..' . '/../lib/CalDAV/Search/CalendarAccountScopedSearchProvider.php', 'OCA\\DAV\\CalDAV\\Search\\SearchPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/Search/SearchPlugin.php', 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\CompFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/CompFilter.php', 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\LimitFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/LimitFilter.php', @@ -201,6 +202,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CardDAV\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/CardDAV/Notification/Notifier.php', 'OCA\\DAV\\CardDAV\\PhotoCache' => __DIR__ . '/..' . '/../lib/CardDAV/PhotoCache.php', 'OCA\\DAV\\CardDAV\\Plugin' => __DIR__ . '/..' . '/../lib/CardDAV/Plugin.php', + 'OCA\\DAV\\CardDAV\\Search\\ContactAccountScopedSearchProvider' => __DIR__ . '/..' . '/../lib/CardDAV/Search/ContactAccountScopedSearchProvider.php', 'OCA\\DAV\\CardDAV\\Security\\CardDavRateLimitingPlugin' => __DIR__ . '/..' . '/../lib/CardDAV/Security/CardDavRateLimitingPlugin.php', 'OCA\\DAV\\CardDAV\\Sharing\\Backend' => __DIR__ . '/..' . '/../lib/CardDAV/Sharing/Backend.php', 'OCA\\DAV\\CardDAV\\Sharing\\Service' => __DIR__ . '/..' . '/../lib/CardDAV/Sharing/Service.php', @@ -443,6 +445,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Search\\ACalendarSearchProvider' => __DIR__ . '/..' . '/../lib/Search/ACalendarSearchProvider.php', 'OCA\\DAV\\Search\\ContactsSearchProvider' => __DIR__ . '/..' . '/../lib/Search/ContactsSearchProvider.php', 'OCA\\DAV\\Search\\EventsSearchProvider' => __DIR__ . '/..' . '/../lib/Search/EventsSearchProvider.php', + 'OCA\\DAV\\Search\\SearchOperatorEvaluator' => __DIR__ . '/..' . '/../lib/Search/SearchOperatorEvaluator.php', 'OCA\\DAV\\Search\\TasksSearchProvider' => __DIR__ . '/..' . '/../lib/Search/TasksSearchProvider.php', 'OCA\\DAV\\Server' => __DIR__ . '/..' . '/../lib/Server.php', 'OCA\\DAV\\ServerFactory' => __DIR__ . '/..' . '/../lib/ServerFactory.php', diff --git a/apps/dav/lib/AppInfo/Application.php b/apps/dav/lib/AppInfo/Application.php index 1f5c4fdba0e1f..f9bb2fd57d10e 100644 --- a/apps/dav/lib/AppInfo/Application.php +++ b/apps/dav/lib/AppInfo/Application.php @@ -21,10 +21,12 @@ use OCA\DAV\CalDAV\Reminder\NotificationProvider\PushProvider; use OCA\DAV\CalDAV\Reminder\NotificationProviderManager; use OCA\DAV\CalDAV\Reminder\Notifier as NotifierCalDAV; +use OCA\DAV\CalDAV\Search\CalendarAccountScopedSearchProvider; use OCA\DAV\CalDAV\TipBroker; use OCA\DAV\Capabilities; use OCA\DAV\CardDAV\ContactsManager; use OCA\DAV\CardDAV\Notification\Notifier as NotifierCardDAV; +use OCA\DAV\CardDAV\Search\ContactAccountScopedSearchProvider; use OCA\DAV\CardDAV\SyncService; use OCA\DAV\ConfigLexicon; use OCA\DAV\Events\AddressBookCreatedEvent; @@ -145,6 +147,9 @@ public function register(IRegistrationContext $context): void { $context->registerSearchProvider(EventsSearchProvider::class); $context->registerSearchProvider(TasksSearchProvider::class); + $context->registerAccountScopedSearchProvider(ContactAccountScopedSearchProvider::class); + $context->registerAccountScopedSearchProvider(CalendarAccountScopedSearchProvider::class); + /** * Register event listeners */ diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index b1028297ec753..be2299c8f824b 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -69,6 +69,14 @@ public function getPrincipalUri(): string { return $this->calendarInfo['principaluri']; } + /** + * The principal URI of the calendar owner, which for a calendar shared with a principal is not + * that principal. + */ + public function getOwnerPrincipalUri(): string { + return $this->calendar->getOwner() ?? $this->getPrincipalUri(); + } + /** * In comparison to getKey() this function returns a human readable (maybe translated) name * @since 13.0.0 diff --git a/apps/dav/lib/CalDAV/Search/CalendarAccountScopedSearchProvider.php b/apps/dav/lib/CalDAV/Search/CalendarAccountScopedSearchProvider.php new file mode 100644 index 0000000000000..2702d7eac7c28 --- /dev/null +++ b/apps/dav/lib/CalDAV/Search/CalendarAccountScopedSearchProvider.php @@ -0,0 +1,549 @@ + self::CONTENT_PROPERTIES, + 'name' => ['SUMMARY'], + 'attendee' => ['ATTENDEE'], + 'organizer' => ['ORGANIZER'], + ]; + + private const COMPONENT_TYPES = ['VEVENT', 'VTODO', 'VJOURNAL']; + + private const GENERATED_CALENDAR_URIS = ['contact_birthdays']; + + public function __construct( + private readonly IL10N $l10n, + private readonly IManager $calendarManager, + private readonly IUserManager $userManager, + private readonly LoggerInterface $logger, + ) { + } + + #[\Override] + public function getId(): string { + return self::ID; + } + + #[\Override] + public function getName(): string { + return $this->l10n->t('Calendar'); + } + + #[\Override] + public function getProperties(): array { + return [ + new SearchPropertyDefinition('content', $this->l10n->t('Content'), searchable: true), + new SearchPropertyDefinition('name', $this->l10n->t('Title'), searchable: true), + new SearchPropertyDefinition('owner', $this->l10n->t('Owner'), selectable: true), + new SearchPropertyDefinition('calendar_name', $this->l10n->t('Calendar'), selectable: true), + new SearchPropertyDefinition('uid', $this->l10n->t('UID'), selectable: true), + new SearchPropertyDefinition('shared', $this->l10n->t('Shared'), SearchPropertyType::Boolean, selectable: true), + new SearchPropertyDefinition('checksum', $this->l10n->t('Checksum'), selectable: true, detailOnly: true), + new SearchPropertyDefinition('event_start', $this->l10n->t('Starts'), SearchPropertyType::DateTime, searchable: true, selectable: true), + new SearchPropertyDefinition('event_end', $this->l10n->t('Ends'), SearchPropertyType::DateTime, searchable: true, selectable: true), + new SearchPropertyDefinition('organizer', $this->l10n->t('Organizer'), searchable: true, selectable: true, detailOnly: true), + new SearchPropertyDefinition('attendee', $this->l10n->t('Attendees'), searchable: true, selectable: true, detailOnly: true), + new SearchPropertyDefinition('location', $this->l10n->t('Location'), selectable: true, detailOnly: true), + ]; + } + + #[\Override] + public function search(string $userId, ?ISearchOperator $filter, int $limit, int $offset = 0): \Generator { + $this->assertAccount($userId); + $options = $this->timerangeOptions($filter); + + $seen = []; + $skipped = 0; + $yielded = 0; + + foreach ($this->calendarsFor($userId) as $calendar) { + foreach ($this->candidates($calendar, $options) as $object) { + $uid = (string)($object['uid'] ?? ''); + if ($uid === '') { + continue; + } + + // Invitations and recurrences repeat a UID, but it is still one item. + if (isset($seen[$uid])) { + continue; + } + $seen[$uid] = true; + + if (!SearchOperatorEvaluator::matches($filter, fn (string $field): array => $this->valuesFor($object, $field))) { + continue; + } + + // The offset counts matches, not candidates. + if ($skipped < $offset) { + $skipped++; + + continue; + } + if ($yielded >= $limit) { + return; + } + $yielded++; + + yield $this->entryFor( + $calendar, + $userId, + $uid, + $this->firstString($object, 'SUMMARY'), + (string)($object['type'] ?? 'event'), + $this->firstTimestamp($object, 'DTSTART'), + $this->firstTimestamp($object, 'DTEND'), + ); + } + } + } + + #[\Override] + public function get(string $userId, string $id): ?AccountScopedSearchResult { + $this->assertAccount($userId); + $found = $this->findObject($userId, $id); + if ($found === null) { + return null; + } + [$calendar, $uid, $vCalendar] = $found; + + $component = $this->mainComponent($vCalendar); + $entry = $this->entryFor( + $calendar, + $userId, + $uid, + (string)($component?->SUMMARY ?? ''), + $component?->name ?? 'event', + $component === null ? null : $this->timestampOf($component, 'DTSTART'), + $component === null ? null : $this->timestampOf($component, 'DTEND'), + ); + + $ics = $vCalendar->serialize(); + $this->addMetaData($entry, 'checksum', static fn (): string => 'SHA256:' . hash('sha256', $ics)); + + $attributes = $component === null ? [] : $this->attributesOf($component); + $this->addMetaData($entry, 'organizer', static function () use ($attributes): string { + if (!isset($attributes['organizer'])) { + throw new \RuntimeException('no organizer recorded on this object'); + } + + return $attributes['organizer']; + }); + $this->addMetaData($entry, 'attendee', static function () use ($attributes): array { + if (!isset($attributes['attendees'])) { + throw new \RuntimeException('no attendees recorded on this object'); + } + + return $attributes['attendees']; + }); + $this->addMetaData($entry, 'location', static function () use ($attributes): string { + if (!isset($attributes['location'])) { + throw new \RuntimeException('no location recorded on this object'); + } + + return $attributes['location']; + }); + + return $entry; + } + + #[\Override] + public function readContent(string $userId, string $id): ?ISimpleFile { + $this->assertAccount($userId); + $found = $this->findObject($userId, $id); + if ($found === null) { + return null; + } + [, $uid, $vCalendar] = $found; + + return new InMemoryFile($this->safeName($uid) . '.ics', $vCalendar->serialize()); + } + + private function entryFor(ICalendar $calendar, string $userId, string $uid, string $summary, string $type, ?int $start, ?int $end): AccountScopedSearchResult { + $entry = new AccountScopedSearchResult( + $this->encodeId($calendar->getUri(), $uid), + $summary !== '' ? $summary : ('Untitled ' . strtolower($type)), + ); + + $this->addMetaData($entry, 'owner', fn (): string => $this->ownerOf($calendar, $userId)); + $this->addMetaData($entry, 'calendar_name', static fn (): string => (string)$calendar->getDisplayName()); + $this->addMetaData($entry, 'uid', static fn (): string => $uid); + $this->addMetaData($entry, 'shared', static fn (): bool + => $calendar instanceof ICalendarIsShared ? $calendar->isShared() : false); + + $this->addMetaData($entry, 'event_start', static function () use ($start): int { + if ($start === null) { + throw new \RuntimeException('this object carries no DTSTART'); + } + + return $start; + }); + + $end ??= $start; + $this->addMetaData($entry, 'event_end', static function () use ($end): int { + if ($end === null) { + throw new \RuntimeException('this object carries no DTEND'); + } + + return $end; + }); + + return $entry; + } + + private function ownerOf(ICalendar $calendar, string $userId): string { + $principal = $calendar instanceof CalendarImpl ? $calendar->getOwnerPrincipalUri() : ''; + + return str_starts_with($principal, 'principals/users/') + ? substr($principal, strlen('principals/users/')) + : $userId; + } + + /** + * @throws AccountUnavailableException + */ + private function assertAccount(string $userId): void { + if (!$this->userManager->userExists($userId)) { + throw new AccountUnavailableException('No such account: ' . $userId); + } + } + + /** + * @return array{0: ICalendar, 1: string, 2: VCalendar}|null + */ + private function findObject(string $userId, string $id): ?array { + $decoded = $this->decodeId($id); + if ($decoded === null) { + return null; + } + [$calendarUri, $uid] = $decoded; + + $calendar = $this->findCalendar($userId, $calendarUri); + if ($calendar === null) { + return null; + } + + $vCalendar = $this->exportObject($calendar, $uid); + + return $vCalendar === null ? null : [$calendar, $uid, $vCalendar]; + } + + private function mainComponent(VCalendar $vCalendar): ?Component { + foreach ($vCalendar->getComponents() as $component) { + if (in_array($component->name, self::COMPONENT_TYPES, true)) { + return $component; + } + } + + return null; + } + + private function timestampOf(Component $component, string $name): ?int { + $property = $component->select($name)[0] ?? null; + + return $property instanceof DateTime ? $property->getDateTime()->getTimestamp() : null; + } + + private function addMetaData(AccountScopedSearchResult $entry, string $name, callable $read): void { + try { + $value = $read(); + if ($value === null) { + $entry->setMetadataError($name, 'not reported by the storage'); + } else { + $entry->setMetadata($name, $value); + } + } catch (\Throwable $e) { + $entry->setMetadataError($name, $e->getMessage()); + } + } + + /** + * Organizer, attendees and location of an object. + * + * @return array + */ + private function attributesOf(Component $component): array { + $attributes = []; + + // "CN
", or whichever of the two is set. + $person = static function (Property $property): string { + $name = trim((string)($property['CN'] ?? '')); + $address = trim(str_ireplace('mailto:', '', (string)$property)); + + if ($name === '' || $address === '') { + return $name . $address; + } + + return $name . ' <' . $address . '>'; + }; + + foreach ($component->select('ORGANIZER') as $organizer) { + $value = $person($organizer); + if ($value !== '') { + $attributes['organizer'] = $value; + } + break; + } + + $attendees = []; + foreach ($component->select('ATTENDEE') as $attendee) { + $value = $person($attendee); + if ($value !== '') { + $attendees[] = $value; + } + } + if ($attendees !== []) { + $attributes['attendees'] = $attendees; + } + + foreach ($component->select('LOCATION') as $location) { + $value = trim((string)$location); + if ($value !== '') { + $attributes['location'] = $value; + } + break; + } + + return $attributes; + } + + /** + * The object as a standalone iCalendar document. + * + * `export()` has no uid filter, so this scans the whole calendar: never call it per search result. + */ + private function exportObject(ICalendar $calendar, string $uid): ?VCalendar { + if (!$calendar instanceof ICalendarExport) { + return null; + } + + try { + foreach ($calendar->export(null) as $vCalendar) { + if ($this->uidOf($vCalendar) === $uid) { + return $vCalendar; + } + } + } catch (\Throwable $e) { + $this->logger->warning('Calendar export failed', ['exception' => $e, 'uid' => $uid]); + } + + return null; + } + + private function uidOf(VCalendar $vCalendar): string { + foreach ($vCalendar->getComponents() as $component) { + $uid = (string)($component->UID ?? ''); + if ($uid !== '') { + return $uid; + } + } + + return ''; + } + + /** + * Every object in the calendar, within the query's time range if it has one. + * + * @param array $options + * @return list> + */ + private function candidates(ICalendar $calendar, array $options): array { + try { + $page = $calendar->search('', [], $options, null); + } catch (\Throwable $e) { + $this->logger->warning('Calendar search failed', ['exception' => $e, 'calendar' => $calendar->getUri()]); + + return []; + } + + $found = []; + foreach ($page as $object) { + $found[(string)($object['uid'] ?? '')] = $object; + } + + // CalDAV has no stable order of its own, and paging needs one. + ksort($found); + + return array_values($found); + } + + /** + * @return list + */ + private function calendarsFor(string $userId): array { + try { + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId); + } catch (\Throwable $e) { + $this->logger->warning('Could not list calendars for an account', ['exception' => $e, 'userId' => $userId]); + + return []; + } + + return array_values(array_filter($calendars, static fn (ICalendar $calendar): bool + // Excludes federated calendars, which cannot be exported. + => $calendar instanceof ICalendarExport + && !in_array($calendar->getUri(), self::GENERATED_CALENDAR_URIS, true))); + } + + private function findCalendar(string $userId, string $calendarUri): ?ICalendar { + foreach ($this->calendarsFor($userId) as $calendar) { + if ($calendar->getUri() === $calendarUri) { + return $calendar; + } + } + + return null; + } + + /** + * @return array + */ + private function timerangeOptions(?ISearchOperator $operation): array { + $start = SearchOperatorEvaluator::mandatoryBound($operation, 'event_start', ISearchComparison::COMPARE_GREATER_THAN_EQUAL); + $end = SearchOperatorEvaluator::mandatoryBound($operation, 'event_end', ISearchComparison::COMPARE_LESS_THAN_EQUAL); + + $options = ['types' => self::COMPONENT_TYPES]; + if ($start !== null) { + $options['timerange']['start'] = (new \DateTimeImmutable())->setTimestamp($start); + } + if ($end !== null) { + $options['timerange']['end'] = (new \DateTimeImmutable())->setTimestamp($end); + } + + return $options; + } + + /** + * @param array $object + * @return list + */ + private function valuesFor(array $object, string $field): array { + if ($field === 'event_start') { + $value = $this->firstTimestamp($object, 'DTSTART'); + + return $value === null ? [] : [$value]; + } + if ($field === 'event_end') { + $value = $this->firstTimestamp($object, 'DTEND') ?? $this->firstTimestamp($object, 'DTSTART'); + + return $value === null ? [] : [$value]; + } + + $values = []; + foreach (self::FIELD_PROPERTIES[$field] ?? [] as $property) { + foreach ($this->properties($object, $property) as $value) { + $values[] = is_scalar($value) ? $value : null; + } + } + + return array_values(array_filter($values, static fn (mixed $v): bool => $v !== null)); + } + + /** + * Property values across every component of the object, flattened. + * + * CalDAV returns `[value, parameters]` for a single property and a list of those for a + * repeated one. + * + * @param array $object + * @return list + */ + private function properties(array $object, string $name): array { + $values = []; + foreach ($object['objects'] ?? [] as $component) { + $raw = $component[$name] ?? null; + if (!is_array($raw) || $raw === []) { + continue; + } + + $entries = is_array($raw[0] ?? null) ? $raw : [$raw]; + foreach ($entries as $entry) { + $values[] = is_array($entry) ? ($entry[0] ?? null) : $entry; + } + } + + return $values; + } + + /** + * @param array $object + */ + private function firstString(array $object, string $name): string { + foreach ($this->properties($object, $name) as $value) { + if (is_scalar($value) && (string)$value !== '') { + return (string)$value; + } + } + + return ''; + } + + /** + * @param array $object + */ + private function firstTimestamp(array $object, string $name): ?int { + foreach ($this->properties($object, $name) as $value) { + if ($value instanceof \DateTimeInterface) { + return $value->getTimestamp(); + } + } + + return null; + } + + private function safeName(string $uid): string { + return trim(preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $uid) ?? '', '_') ?: 'event'; + } + + private function encodeId(string $calendarUri, string $uid): string { + return rawurlencode($calendarUri) . ':' . $uid; + } + + /** + * @return array{0: string, 1: string}|null + */ + private function decodeId(string $id): ?array { + $parts = explode(':', $id, 2); + if (count($parts) !== 2) { + return null; + } + + return [rawurldecode($parts[0]), $parts[1]]; + } +} diff --git a/apps/dav/lib/CardDAV/Search/ContactAccountScopedSearchProvider.php b/apps/dav/lib/CardDAV/Search/ContactAccountScopedSearchProvider.php new file mode 100644 index 0000000000000..3acca44c2ced2 --- /dev/null +++ b/apps/dav/lib/CardDAV/Search/ContactAccountScopedSearchProvider.php @@ -0,0 +1,439 @@ + self::CONTENT_PROPERTIES, + 'name' => ['FN', 'N', 'NICKNAME'], + 'email' => ['EMAIL'], + 'phone' => ['TEL'], + 'organisation' => ['ORG'], + ]; + + /** The account directory, regenerated from user accounts rather than written by a custodian. */ + private const GENERATED_ADDRESS_BOOK_URIS = ['system']; + + /** @var array>> */ + private array $addressBookCache = []; + + public function __construct( + private readonly IL10N $l10n, + private readonly CardDavBackend $backend, + private readonly IUserManager $userManager, + private readonly LoggerInterface $logger, + ) { + } + + #[\Override] + public function getId(): string { + return self::ID; + } + + #[\Override] + public function getName(): string { + return $this->l10n->t('Contacts'); + } + + #[\Override] + public function getProperties(): array { + return [ + new SearchPropertyDefinition('content', $this->l10n->t('Content'), searchable: true), + new SearchPropertyDefinition('name', $this->l10n->t('Name'), searchable: true), + new SearchPropertyDefinition('owner', $this->l10n->t('Owner'), selectable: true), + new SearchPropertyDefinition('address_book_name', $this->l10n->t('Address book'), selectable: true), + new SearchPropertyDefinition('modified', $this->l10n->t('Modified'), SearchPropertyType::DateTime, selectable: true), + new SearchPropertyDefinition('checksum', $this->l10n->t('Checksum'), selectable: true), + new SearchPropertyDefinition('shared', $this->l10n->t('Shared'), SearchPropertyType::Boolean, selectable: true), + new SearchPropertyDefinition('email', $this->l10n->t('Emails'), searchable: true, selectable: true), + new SearchPropertyDefinition('phone', $this->l10n->t('Phones'), searchable: true, selectable: true), + new SearchPropertyDefinition('organisation', $this->l10n->t('Organisation'), searchable: true, selectable: true), + new SearchPropertyDefinition('job_title', $this->l10n->t('Job title'), selectable: true), + new SearchPropertyDefinition('categories', $this->l10n->t('Categories'), selectable: true), + ]; + } + + #[\Override] + public function search(string $userId, ?ISearchOperator $filter, int $limit, int $offset = 0): \Generator { + $this->assertAccount($userId); + $books = $this->addressBooksFor($userId); + + $skipped = 0; + $yielded = 0; + foreach ($this->candidates($userId) as $row) { + $book = $books[(int)($row['addressbookid'] ?? 0)] ?? null; + $uri = (string)($row['uri'] ?? ''); + if ($book === null || $uri === '') { + continue; + } + + $card = $this->parse((string)($row['carddata'] ?? '')); + if ($card === null) { + continue; + } + + if (!SearchOperatorEvaluator::matches($filter, fn (string $field): array => $this->valuesFor($card, $field))) { + continue; + } + + // The offset counts matches, not candidates. + if ($skipped < $offset) { + $skipped++; + + continue; + } + if ($yielded >= $limit) { + return; + } + $yielded++; + + yield $this->entryFor($userId, $book, $uri, $card, (string)($row['carddata'] ?? '')); + } + } + + #[\Override] + public function get(string $userId, string $id): ?AccountScopedSearchResult { + $this->assertAccount($userId); + $found = $this->findCard($userId, $id); + if ($found === null) { + return null; + } + [$book, $cardUri, $data] = $found; + + $card = $this->parse($data); + + return $card === null ? null : $this->entryFor($userId, $book, $cardUri, $card, $data); + } + + #[\Override] + public function readContent(string $userId, string $id): ?ISimpleFile { + $this->assertAccount($userId); + $found = $this->findCard($userId, $id); + if ($found === null) { + return null; + } + [, $cardUri, $data] = $found; + + $card = $this->parse($data); + $name = $card === null ? '' : (string)($card->FN ?? ''); + + return new InMemoryFile($this->safeName($name !== '' ? $name : $cardUri) . '.vcf', $data); + } + + /** + * @param array $book + */ + private function entryFor(string $userId, array $book, string $uri, VCard $card, string $data): AccountScopedSearchResult { + $name = (string)($card->FN ?? ''); + $entry = new AccountScopedSearchResult( + $this->encodeId((string)$book['uri'], $uri), + $name !== '' ? $name : $uri, + ); + + $this->addMetaData($entry, 'owner', fn (): string => $this->uidOfPrincipal( + (string)($book['{http://owncloud.org/ns}owner-principal'] ?? $book['principaluri'] ?? '')) ?: $userId); + $this->addMetaData($entry, 'address_book_name', static fn (): string + => (string)($book['{DAV:}displayname'] ?? $book['uri'])); + $this->addMetaData($entry, 'shared', fn (): bool => $this->isShared($book)); + + $rev = $this->revTimestamp($card); + $this->addMetaData($entry, 'modified', static function () use ($rev): int { + if ($rev === null) { + throw new \RuntimeException('the card carries no REV property'); + } + + return $rev; + }); + + // Over the stored bytes, which is what readContent() returns. + $this->addMetaData($entry, 'checksum', static fn (): string => 'SHA256:' . hash('sha256', $data)); + + $this->addMetaData($entry, 'email', fn (): array => $this->propertyValues($card, 'EMAIL')); + $this->addMetaData($entry, 'phone', fn (): array => $this->propertyValues($card, 'TEL')); + $this->addMetaData($entry, 'categories', fn (): array => $this->propertyValues($card, 'CATEGORIES')); + + $organisation = $this->propertyValues($card, 'ORG'); + $this->addMetaData($entry, 'organisation', static function () use ($organisation): string { + if ($organisation === []) { + throw new \RuntimeException('no organisation recorded on this card'); + } + + return $organisation[0]; + }); + + $jobTitle = $this->propertyValues($card, 'TITLE'); + $this->addMetaData($entry, 'job_title', static function () use ($jobTitle): string { + if ($jobTitle === []) { + throw new \RuntimeException('no job title recorded on this card'); + } + + return $jobTitle[0]; + }); + + return $entry; + } + + /** + * @throws AccountUnavailableException + */ + private function assertAccount(string $userId): void { + if (!$this->userManager->userExists($userId)) { + throw new AccountUnavailableException('No such account: ' . $userId); + } + } + + /** + * @return array{0: array, 1: string, 2: string}|null + */ + private function findCard(string $userId, string $id): ?array { + $decoded = $this->decodeId($id); + if ($decoded === null) { + return null; + } + [$bookUri, $cardUri] = $decoded; + + $book = $this->findAddressBook($userId, $bookUri); + if ($book === null) { + return null; + } + + $data = $this->cardData((int)$book['id'], $cardUri); + + return $data === null ? null : [$book, $cardUri, $data]; + } + + /** + * Attach one field, or record why it could not be read. + */ + private function addMetaData(AccountScopedSearchResult $entry, string $name, callable $read): void { + try { + $value = $read(); + if ($value === null) { + $entry->setMetadataError($name, 'not reported by the storage'); + } else { + $entry->setMetadata($name, $value); + } + } catch (\Throwable $e) { + $entry->setMetadataError($name, $e->getMessage()); + } + } + + /** + * Every card in the account's address books. + * + * @return list> + */ + private function candidates(string $userId): array { + try { + $rows = $this->backend->searchPrincipalUri( + 'principals/users/' . $userId, + '', + self::CONTENT_PROPERTIES, + ['wildcard' => true], + ); + } catch (\Throwable $e) { + throw new \RuntimeException('Could not read contacts for ' . $userId, 0, $e); + } + + $found = []; + foreach ($rows as $row) { + $found[$row['addressbookid'] . ':' . $row['uri']] = $row; + } + + // CardDAV has no stable order of its own, and paging needs one. + ksort($found); + + return array_values($found); + } + + /** + * The account's own and shared-with address books, keyed by id. + * + * @return array> + */ + private function addressBooksFor(string $userId): array { + if (isset($this->addressBookCache[$userId])) { + return $this->addressBookCache[$userId]; + } + + try { + $books = $this->backend->getAddressBooksForUser('principals/users/' . $userId); + } catch (\Throwable $e) { + $this->logger->warning('Could not list address books for an account', ['exception' => $e, 'userId' => $userId]); + + return []; + } + + $byId = []; + foreach ($books as $book) { + if (in_array((string)($book['uri'] ?? ''), self::GENERATED_ADDRESS_BOOK_URIS, true)) { + continue; + } + $byId[(int)$book['id']] = $book; + } + + $this->addressBookCache[$userId] = $byId; + + return $byId; + } + + /** + * @return array|null + */ + private function findAddressBook(string $userId, string $uri): ?array { + foreach ($this->addressBooksFor($userId) as $book) { + if ((string)$book['uri'] === $uri) { + return $book; + } + } + + return null; + } + + private function cardData(int $addressBookId, string $cardUri): ?string { + if ($cardUri === '') { + return null; + } + + try { + $card = $this->backend->getCard($addressBookId, $cardUri); + } catch (\Throwable $e) { + $this->logger->debug('Contact card could not be read', ['exception' => $e, 'cardUri' => $cardUri]); + + return null; + } + + $data = is_array($card) ? ($card['carddata'] ?? null) : null; + + return is_string($data) && $data !== '' ? $data : null; + } + + private function parse(string $data): ?VCard { + if ($data === '') { + return null; + } + + try { + $card = Reader::read($data); + } catch (\Throwable $e) { + $this->logger->warning('Unparseable vCard skipped', ['exception' => $e]); + + return null; + } + + return $card instanceof VCard ? $card : null; + } + + /** + * @return list + */ + private function valuesFor(VCard $card, string $field): array { + $values = []; + foreach (self::FIELD_PROPERTIES[$field] ?? [] as $name) { + foreach ($card->select($name) as $property) { + $value = (string)$property; + if ($value !== '') { + $values[] = $value; + } + } + } + + return $values; + } + + /** + * @return list + */ + private function propertyValues(VCard $card, string $property): array { + $found = []; + foreach ($card->select($property) as $entry) { + $value = trim((string)$entry); + if ($value !== '') { + $found[] = $value; + } + } + + return $found; + } + + private function revTimestamp(VCard $card): ?int { + $rev = (string)($card->REV ?? ''); + if ($rev === '') { + return null; + } + $time = strtotime($rev); + + return $time === false ? null : $time; + } + + /** + * @param array $book + */ + private function isShared(array $book): bool { + $owner = (string)($book['{http://owncloud.org/ns}owner-principal'] ?? ''); + $principal = (string)($book['principaluri'] ?? ''); + if ($owner !== '' && $owner !== $principal) { + return true; + } + + try { + return $this->backend->getShares((int)$book['id']) !== []; + } catch (\Throwable) { + return false; + } + } + + private function uidOfPrincipal(string $principalUri): string { + return str_starts_with($principalUri, 'principals/users/') + ? substr($principalUri, strlen('principals/users/')) + : ''; + } + + private function safeName(string $name): string { + return trim(preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $name) ?? '', '_') ?: 'contact'; + } + + private function encodeId(string $bookUri, string $cardUri): string { + return rawurlencode($bookUri) . ':' . $cardUri; + } + + /** + * @return array{0: string, 1: string}|null + */ + private function decodeId(string $id): ?array { + $parts = explode(':', $id, 2); + if (count($parts) !== 2) { + return null; + } + + return [rawurldecode($parts[0]), $parts[1]]; + } +} diff --git a/apps/dav/lib/Search/SearchOperatorEvaluator.php b/apps/dav/lib/Search/SearchOperatorEvaluator.php new file mode 100644 index 0000000000000..36d9fbd61d43f --- /dev/null +++ b/apps/dav/lib/Search/SearchOperatorEvaluator.php @@ -0,0 +1,221 @@ + $values field name -> the card's values for it + */ + public static function matches(?ISearchOperator $operator, callable $values): bool { + if ($operator === null) { + return true; + } + + return self::node($operator, $values); + } + + /** + * @param callable(string): list $values + */ + private static function node(ISearchOperator $operator, callable $values): bool { + if ($operator instanceof ISearchComparison) { + return self::condition($operator, $values($operator->getField())); + } + + if (!$operator instanceof ISearchBinaryOperator) { + return true; + } + + return match ($operator->getType()) { + ISearchBinaryOperator::OPERATOR_OR => array_reduce( + $operator->getArguments(), + static fn (bool $carry, ISearchOperator $child): bool => $carry || self::node($child, $values), + false, + ), + ISearchBinaryOperator::OPERATOR_NOT => !self::node($operator->getArguments()[0], $values), + default => array_reduce( + $operator->getArguments(), + static fn (bool $carry, ISearchOperator $child): bool => $carry && self::node($child, $values), + true, + ), + }; + } + + /** + * @param list $values + */ + private static function condition(ISearchComparison $comparison, array $values): bool { + $needle = $comparison->getValue(); + + return match ($comparison->getType()) { + ISearchComparison::COMPARE_LIKE, ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => self::likeCondition( + (string)$needle, + $comparison->getType() === ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE, + $values, + ), + ISearchComparison::COMPARE_EQUAL => self::any($values, static fn (string $v): bool + => strcasecmp($v, trim((string)$needle)) === 0), + ISearchComparison::COMPARE_GREATER_THAN_EQUAL, + ISearchComparison::COMPARE_LESS_THAN_EQUAL, + ISearchComparison::COMPARE_GREATER_THAN, + ISearchComparison::COMPARE_LESS_THAN => self::numericCondition($comparison->getType(), $values, $needle), + // Never false: an unsupported operator is a bug, not "no results". + default => throw new \RuntimeException( + 'Cannot evaluate operator "' . $comparison->getType() . '" on field "' . $comparison->getField() . '"', + ), + }; + } + + /** + * @param list $values + */ + private static function likeCondition(string $pattern, bool $caseSensitive, array $values): bool { + $regex = self::likeToRegex($pattern) . ($caseSensitive ? '' : 'i'); + + return self::any($values, static fn (string $v): bool => preg_match($regex, $v) === 1); + } + + /** + * A SQL LIKE pattern as a regex, honouring `\%`, `\_` and `\\` escapes. + */ + private static function likeToRegex(string $pattern): string { + $regex = ''; + $length = strlen($pattern); + for ($i = 0; $i < $length; $i++) { + $char = $pattern[$i]; + if ($char === '\\' && $i + 1 < $length) { + $regex .= preg_quote($pattern[++$i], '/'); + + continue; + } + $regex .= match ($char) { + '%' => '.*', + '_' => '.', + default => preg_quote($char, '/'), + }; + } + + return '/^' . $regex . '$/'; + } + + /** + * @param list $values + */ + private static function numericCondition(string $type, array $values, mixed $needle): bool { + $bound = self::toFloat($needle); + if ($bound === null) { + // Neither a number nor a date: matches nothing, rather than crashing on a cast. + return false; + } + + return match ($type) { + ISearchComparison::COMPARE_GREATER_THAN_EQUAL => self::anyNumeric($values, static fn (float $v): bool => $v >= $bound), + ISearchComparison::COMPARE_LESS_THAN_EQUAL => self::anyNumeric($values, static fn (float $v): bool => $v <= $bound), + ISearchComparison::COMPARE_GREATER_THAN => self::anyNumeric($values, static fn (float $v): bool => $v > $bound), + ISearchComparison::COMPARE_LESS_THAN => self::anyNumeric($values, static fn (float $v): bool => $v < $bound), + default => false, + }; + } + + private static function toFloat(mixed $value): ?float { + if ($value instanceof \DateTime) { + return (float)$value->getTimestamp(); + } + + return is_numeric($value) ? (float)$value : null; + } + + /** + * @param list $values + * @param callable(string): bool $test + */ + private static function any(array $values, callable $test): bool { + foreach ($values as $value) { + if ($value !== null && !is_array($value) && $test((string)$value)) { + return true; + } + } + + return false; + } + + /** + * @param list $values + * @param callable(float): bool $test + */ + private static function anyNumeric(array $values, callable $test): bool { + foreach ($values as $value) { + if (is_numeric($value) && $test((float)$value)) { + return true; + } + } + + return false; + } + + /** + * A numeric bound every matching item must satisfy, for pushing a range into a backend query. + * + * Only conditions ANDed all the way up to the root qualify: under an `or` or a `not` the bound + * does not constrain the whole result. + */ + public static function mandatoryBound(?ISearchOperator $operator, string $field, string $comparisonType): ?int { + if ($operator === null) { + return null; + } + + $bound = null; + self::eachMandatory($operator, static function (ISearchComparison $c) use ($field, $comparisonType, &$bound): void { + if ($c->getField() !== $field || $c->getType() !== $comparisonType) { + return; + } + $number = self::toFloat($c->getValue()); + if ($number === null) { + return; + } + $value = (int)$number; + $bound = $bound === null + ? $value + : ($comparisonType === ISearchComparison::COMPARE_GREATER_THAN_EQUAL ? max($bound, $value) : min($bound, $value)); + }); + + return $bound; + } + + /** + * Visit comparisons that are ANDed all the way to the root. + * + * @param callable(ISearchComparison): void $fn + */ + private static function eachMandatory(ISearchOperator $operator, callable $fn): void { + if ($operator instanceof ISearchComparison) { + $fn($operator); + + return; + } + + if ($operator instanceof ISearchBinaryOperator && $operator->getType() === ISearchBinaryOperator::OPERATOR_AND) { + foreach ($operator->getArguments() as $child) { + self::eachMandatory($child, $fn); + } + } + } +} diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index e894a4c212046..a005578aa2428 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -88,6 +88,7 @@ 'OCA\\Files\\Migration\\Version2003Date20241021095629' => $baseDir . '/../lib/Migration/Version2003Date20241021095629.php', 'OCA\\Files\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', 'OCA\\Files\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', + 'OCA\\Files\\Search\\FileAccountScopedSearchProvider' => $baseDir . '/../lib/Search/FileAccountScopedSearchProvider.php', 'OCA\\Files\\Search\\FilesSearchProvider' => $baseDir . '/../lib/Search/FilesSearchProvider.php', 'OCA\\Files\\Service\\ChunkedUploadConfig' => $baseDir . '/../lib/Service/ChunkedUploadConfig.php', 'OCA\\Files\\Service\\DirectEditingService' => $baseDir . '/../lib/Service/DirectEditingService.php', diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 342bc63e2fe04..6f0f0d4cc602f 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -103,6 +103,7 @@ class ComposerStaticInitFiles 'OCA\\Files\\Migration\\Version2003Date20241021095629' => __DIR__ . '/..' . '/../lib/Migration/Version2003Date20241021095629.php', 'OCA\\Files\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', 'OCA\\Files\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', + 'OCA\\Files\\Search\\FileAccountScopedSearchProvider' => __DIR__ . '/..' . '/../lib/Search/FileAccountScopedSearchProvider.php', 'OCA\\Files\\Search\\FilesSearchProvider' => __DIR__ . '/..' . '/../lib/Search/FilesSearchProvider.php', 'OCA\\Files\\Service\\ChunkedUploadConfig' => __DIR__ . '/..' . '/../lib/Service/ChunkedUploadConfig.php', 'OCA\\Files\\Service\\DirectEditingService' => __DIR__ . '/..' . '/../lib/Service/DirectEditingService.php', diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index 2a61519da737f..56fcc6ce2c4aa 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -35,6 +35,7 @@ use OCA\Files\Listener\SyncLivePhotosListener; use OCA\Files\Listener\UserFirstTimeLoggedInListener; use OCA\Files\Notification\Notifier; +use OCA\Files\Search\FileAccountScopedSearchProvider; use OCA\Files\Search\FilesSearchProvider; use OCA\Files\Sharing\Permission\NodeCreateSharePermissionType; use OCA\Files\Sharing\Permission\NodeDeleteSharePermissionType; @@ -96,6 +97,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(ShareDeletedFromSelfEvent::class, Listener::class); $context->registerSearchProvider(FilesSearchProvider::class); + $context->registerAccountScopedSearchProvider(FileAccountScopedSearchProvider::class); $context->registerNotifierService(Notifier::class); $context->registerDashboardWidget(FavoriteWidget::class); diff --git a/apps/files/lib/Search/FileAccountScopedSearchProvider.php b/apps/files/lib/Search/FileAccountScopedSearchProvider.php new file mode 100644 index 0000000000000..03c85a71c865f --- /dev/null +++ b/apps/files/lib/Search/FileAccountScopedSearchProvider.php @@ -0,0 +1,533 @@ + 'mtime', + 'created' => 'creation_time', + ]; + + public function __construct( + private readonly IL10N $l10n, + private readonly IRootFolder $rootFolder, + private readonly IShareManager $shareManager, + private readonly ISystemTagObjectMapper $tagObjectMapper, + private readonly ISystemTagManager $tagManager, + private readonly IFullTextSearchManager $fullTextSearchManager, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + } + + #[\Override] + public function getId(): string { + return self::ID; + } + + #[\Override] + public function getName(): string { + return $this->l10n->t('Files'); + } + + #[\Override] + public function getProperties(): array { + $properties = [ + new SearchPropertyDefinition('name', $this->l10n->t('Name'), searchable: true), + new SearchPropertyDefinition('owner', $this->l10n->t('Owner'), selectable: true), + new SearchPropertyDefinition('path', $this->l10n->t('Path'), searchable: true, selectable: true), + new SearchPropertyDefinition('shared_externally', $this->l10n->t('Shared externally'), SearchPropertyType::Boolean, searchable: true), + new SearchPropertyDefinition('mimetype', $this->l10n->t('File type'), searchable: true, selectable: true), + new SearchPropertyDefinition('size', $this->l10n->t('Size'), SearchPropertyType::Integer, searchable: true, selectable: true), + new SearchPropertyDefinition('modified', $this->l10n->t('Modified'), SearchPropertyType::DateTime, searchable: true, selectable: true), + new SearchPropertyDefinition('created', $this->l10n->t('Created'), SearchPropertyType::DateTime, searchable: true, selectable: true), + new SearchPropertyDefinition('mount_point', $this->l10n->t('Mount point'), selectable: true), + new SearchPropertyDefinition('checksum', $this->l10n->t('Checksum'), selectable: true), + new SearchPropertyDefinition('share_status', $this->l10n->t('Share status'), SearchPropertyType::Object, selectable: true, detailOnly: true), + new SearchPropertyDefinition('shared_with', $this->l10n->t('Shared with'), selectable: true, detailOnly: true), + new SearchPropertyDefinition('tags', $this->l10n->t('Tags'), selectable: true, detailOnly: true), + ]; + + // Only offered when an index can answer it. + if ($this->indexAvailable()) { + $properties[] = new SearchPropertyDefinition('content', $this->l10n->t('Content'), searchable: true, indexed: true); + } + + return $properties; + } + + /** + * Not cached, as the index can be installed or removed at any time. + */ + private function indexAvailable(): bool { + try { + if (!$this->fullTextSearchManager->isAvailable() + || !$this->fullTextSearchManager->isProviderIndexed(self::ID)) { + return false; + } + + return $this->appConfig->getValueString('fulltextsearch', 'search_platform', '') !== ''; + } catch (\Throwable $e) { + $this->logger->debug('Fulltextsearch availability check failed', ['exception' => $e]); + + return false; + } + } + + #[\Override] + public function search(string $userId, ?ISearchOperator $filter, int $limit, int $offset = 0): \Generator { + $userFolder = $this->userFolder($userId); + + // A folder is not an item: its files are found on their own. + $operators = [ + new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_NOT, [ + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', FileInfo::MIMETYPE_FOLDER), + ]), + ]; + if ($filter !== null) { + $contentMatches = $this->resolveContentTerms($filter, $userId, []); + $externallyShared = $this->usesField($filter, 'shared_externally') + ? $this->externallySharedFileIds($userId) + : []; + $operators[] = $this->resolveOperator($filter, $contentMatches, $externallyShared); + } + + $query = new SearchQuery( + new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, $operators), + $limit, + $offset, + [new SearchOrder(ISearchOrder::DIRECTION_ASCENDING, 'fileid')], + null, + false, + ['creation_time'], + ); + + foreach ($userFolder->search($query) as $node) { + if ($node instanceof File) { + yield $this->entryFor($userFolder, $node, false); + } + } + } + + #[\Override] + public function get(string $userId, string $id): ?AccountScopedSearchResult { + $userFolder = $this->userFolder($userId); + $node = $this->findFile($userFolder, $id); + + return $node === null ? null : $this->entryFor($userFolder, $node, true); + } + + #[\Override] + public function readContent(string $userId, string $id): ?ISimpleFile { + $node = $this->findFile($this->userFolder($userId), $id); + + return $node === null ? null : new SimpleFile($node); + } + + /** + * @throws AccountUnavailableException + */ + private function userFolder(string $userId): Folder { + try { + return $this->rootFolder->getUserFolder($userId); + } catch (UserNotFoundException|NotPermittedException $e) { + throw new AccountUnavailableException($e->getMessage(), 0, $e); + } + } + + private function findFile(Folder $userFolder, string $id): ?File { + if (!ctype_digit($id)) { + return null; + } + + $node = $userFolder->getFirstNodeById((int)$id); + + return $node instanceof File ? $node : null; + } + + /** + * @param bool $detail Whether to also read the detail-only properties + */ + private function entryFor(Folder $userFolder, File $node, bool $detail): AccountScopedSearchResult { + $entry = new AccountScopedSearchResult((string)$node->getId(), $node->getName()); + + $this->addMetaData($entry, 'owner', static fn (): ?string => $node->getOwner()?->getUID()); + $this->addMetaData($entry, 'path', static fn (): string => $userFolder->getRelativePath($node->getPath()) ?? $node->getPath()); + $this->addMetaData($entry, 'mimetype', static fn (): string => $node->getMimetype()); + $this->addMetaData($entry, 'size', static fn (): int|float => $node->getSize()); + $this->addMetaData($entry, 'modified', static fn (): int => $node->getMTime()); + + $this->addMetaData($entry, 'created', static function () use ($node): int { + $created = $node->getCreationTime(); + if ($created === 0) { + throw new \RuntimeException('creation time not recorded by the storage'); + } + + return $created; + }); + + $this->addMetaData($entry, 'mount_point', static fn (): string => $node->getMountPoint()->getMountPoint()); + + // Stored as "TYPE:VALUE", and only when a client supplied one on upload. + $this->addMetaData($entry, 'checksum', static function () use ($node): string { + $checksum = $node->getChecksum(); + if ($checksum === '') { + throw new \RuntimeException('no checksum stored for this file'); + } + + return $checksum; + }); + + if (!$detail) { + return $entry; + } + + $shareStatus = null; + $this->addMetaData($entry, 'share_status', function () use ($node, &$shareStatus): array { + $shareStatus = $this->shareStatus($node); + + return $shareStatus; + }); + $this->addMetaData($entry, 'shared_with', static function () use (&$shareStatus): array { + if ($shareStatus === null) { + throw new \RuntimeException('share status not captured'); + } + + $recipients = []; + foreach ($shareStatus['shares'] as $share) { + $recipient = $share['recipient'] ?? ''; + if ($recipient !== '') { + $recipients[] = $recipient; + } + } + + return array_values(array_unique($recipients)); + }); + + $this->addMetaData($entry, 'tags', fn (): array => $this->visibleTags($node)); + + return $entry; + } + + /** + * Every distinct `content` term in a tree, resolved once each. + * + * @param array> $resolved accumulated so far + * @return array> + */ + private function resolveContentTerms(ISearchOperator $operator, string $userId, array $resolved): array { + if ($operator instanceof ISearchBinaryOperator) { + foreach ($operator->getArguments() as $child) { + $resolved = $this->resolveContentTerms($child, $userId, $resolved); + } + + return $resolved; + } + + if ($operator instanceof ISearchComparison && $operator->getField() === 'content') { + $term = $this->contentTerm($operator); + if (!array_key_exists($term, $resolved)) { + $resolved[$term] = $this->contentMatchedFileIds($term, $userId); + } + } + + return $resolved; + } + + private function usesField(ISearchOperator $operator, string $field): bool { + if ($operator instanceof ISearchBinaryOperator) { + foreach ($operator->getArguments() as $child) { + if ($this->usesField($child, $field)) { + return true; + } + } + + return false; + } + + return $operator instanceof ISearchComparison && $operator->getField() === $field; + } + + /** + * Rewrite a tree into filecache fields, with `content` and `shared_externally` resolved to fileids. + * + * @param array> $contentMatches + * @param list $externallyShared + */ + private function resolveOperator(ISearchOperator $operator, array $contentMatches, array $externallyShared): ISearchOperator { + if ($operator instanceof ISearchBinaryOperator) { + return new SearchBinaryOperator( + $operator->getType(), + array_map(fn (ISearchOperator $child): ISearchOperator + => $this->resolveOperator($child, $contentMatches, $externallyShared), $operator->getArguments()), + ); + } + + if (!$operator instanceof ISearchComparison) { + return $operator; + } + + if ($operator->getField() === 'content') { + return $this->fileIdsToComparison($contentMatches[$this->contentTerm($operator)]); + } + + if ($operator->getField() === 'shared_externally') { + if ($operator->getType() !== ISearchComparison::COMPARE_EQUAL) { + throw new \InvalidArgumentException('Unsupported comparison for field shared_externally: ' . $operator->getType()); + } + + $ids = $this->fileIdsToComparison($externallyShared); + + return filter_var($operator->getValue(), FILTER_VALIDATE_BOOL) + ? $ids + : new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_NOT, [$ids]); + } + + // The filecache path is relative to the storage root. A file received through a share is + // stored under the owner's path, so the recipient's path does not match it. + if ($operator->getField() === 'path') { + $value = $operator->getValue(); + + return new SearchComparison( + $operator->getType(), + 'path', + is_array($value) + ? array_map(static fn (mixed $path): string => 'files/' . ltrim((string)$path, '/'), $value) + : 'files/' . ltrim((string)$value, '/'), + $operator->getExtra(), + ); + } + + if (isset(self::FIELD_COLUMNS[$operator->getField()])) { + return new SearchComparison( + $operator->getType(), + self::FIELD_COLUMNS[$operator->getField()], + $operator->getValue(), + $operator->getExtra(), + ); + } + + return $operator; + } + + /** + * The text a `content` comparison searches for. The index always matches a term anywhere in + * the content, so the wildcards of a LIKE pattern are dropped. + */ + private function contentTerm(ISearchComparison $comparison): string { + $value = (string)$comparison->getValue(); + if (!in_array($comparison->getType(), [ISearchComparison::COMPARE_LIKE, ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE], true)) { + return $value; + } + + $term = preg_replace('/(? + */ + private function contentMatchedFileIds(string $term, string $userId): array { + try { + $results = $this->fullTextSearchManager->search([ + 'providers' => [self::ID], + 'search' => $term, + 'size' => self::CONTENT_MATCH_LIMIT, + ], $userId); + } catch (\Throwable $e) { + throw new \RuntimeException('The content index did not answer for ' . $userId, 0, $e); + } + + $fileIds = []; + foreach ($results as $result) { + foreach ($result->getDocuments() as $document) { + $fileIds[(int)$document->getId()] = true; + } + } + + // ISearchResult exposes no total, so a full page may be a subset. + if (count($fileIds) >= self::CONTENT_MATCH_LIMIT) { + throw new SearchTruncatedException( + 'Content search for "' . $term . '" matched at least ' . self::CONTENT_MATCH_LIMIT + . ' files for ' . $userId . ' and cannot be answered exhaustively', + ); + } + + return array_keys($fileIds); + } + + /** + * A set of fileids as a filecache condition. + * + * @param list $ids + */ + private function fileIdsToComparison(array $ids): ISearchOperator { + if ($ids === []) { + return new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'fileid', -1); + } + + return new SearchComparison(ISearchComparison::COMPARE_IN, 'fileid', $ids); + } + + private function addMetaData(AccountScopedSearchResult $entry, string $name, callable $read): void { + try { + $value = $read(); + if ($value === null) { + $entry->setMetadataError($name, 'not reported by the storage'); + } else { + $entry->setMetadata($name, $value); + } + } catch (\Throwable $e) { + $entry->setMetadataError($name, $e->getMessage()); + } + } + + /** + * Fileids of everything the account shared outside the organisation. + * + * @return list + */ + private function externallySharedFileIds(string $userId): array { + $fileIds = []; + foreach (self::EXTERNAL_SHARE_TYPES as $shareType) { + $offset = 0; + do { + $page = $this->shareManager->getSharesBy($userId, $shareType, null, false, self::SHARE_PAGE, $offset); + foreach ($page as $share) { + $fileIds[$share->getNodeId()] = true; + } + $offset += count($page); + } while (count($page) === self::SHARE_PAGE); + } + + return array_keys($fileIds); + } + + /** + * @return array{shared: bool, externally: bool, shares: list} + */ + private function shareStatus(File $node): array { + $owner = $node->getOwner()?->getUID(); + if ($owner === null) { + throw new \RuntimeException('owner unknown, so shares cannot be enumerated'); + } + + $externally = false; + $entries = []; + foreach ([...self::EXTERNAL_SHARE_TYPES, IShare::TYPE_USER, IShare::TYPE_GROUP] as $shareType) { + $external = in_array($shareType, self::EXTERNAL_SHARE_TYPES, true); + $offset = 0; + do { + $page = $this->shareManager->getSharesBy($owner, $shareType, $node, true, self::SHARE_PAGE, $offset); + foreach ($page as $share) { + $externally = $externally || $external; + $entries[] = [ + 'type' => $shareType, + 'external' => $external, + 'recipient' => $share->getSharedWith(), + // Not the token: a public-link token is a credential. + 'shareId' => $share->getId(), + ]; + } + $offset += count($page); + } while (count($page) === self::SHARE_PAGE); + } + + return [ + 'shared' => $entries !== [], + 'externally' => $externally, + 'shares' => $entries, + ]; + } + + /** + * The user-visible tags on this file. + * + * @return list + */ + private function visibleTags(File $node): array { + $objectId = (string)$node->getId(); + $assigned = $this->tagObjectMapper->getTagIdsForObjects([$objectId], 'files'); + $tagIds = $assigned[$objectId] ?? []; + if ($tagIds === []) { + return []; + } + + try { + $tags = $this->tagManager->getTagsByIds($tagIds); + } catch (\Throwable $e) { + $this->logger->debug('Could not read system tags for a search result', [ + 'exception' => $e, + 'fileId' => $objectId, + ]); + + return []; + } + + $names = []; + foreach ($tags as $tag) { + if ($tag->isUserVisible()) { + $names[] = $tag->getName(); + } + } + + return $names; + } +} diff --git a/core/Command/Search/ProvidersCommand.php b/core/Command/Search/ProvidersCommand.php new file mode 100644 index 0000000000000..f6e839d3cdaf1 --- /dev/null +++ b/core/Command/Search/ProvidersCommand.php @@ -0,0 +1,56 @@ +registry->getProviders(); + if ($providers === []) { + $output->writeln('No account-scoped search providers are registered.'); + + return ExitCode::Success; + } + + $tree = []; + foreach ($providers as $id => $provider) { + $fields = []; + foreach ($provider->getProperties() as $property) { + $flags = array_keys(array_filter([ + 'searchable' => $property->isSearchable(), + 'selectable' => $property->isSelectable(), + 'detail-only' => $property->isDetailOnly(), + 'indexed' => $property->isIndexed(), + ])); + $fields[] = $property->getName() . ' — ' . $property->getTitle() + . ' [' . implode(', ', [$property->getType()->value, ...$flags]) . ']'; + } + $tree[$id . ' (' . $provider->getName() . ')'] = $fields; + } + + $output->writeTree($tree, 'Account-scoped search providers'); + + return ExitCode::Success; + } +} diff --git a/core/Command/Search/QueryCommand.php b/core/Command/Search/QueryCommand.php new file mode 100644 index 0000000000000..d3e72c3194434 --- /dev/null +++ b/core/Command/Search/QueryCommand.php @@ -0,0 +1,143 @@ +registry->getProvider($provider); + if ($searchProvider === null) { + $output->writeln('No such provider "' . $provider . '". Registered: ' + . implode(', ', array_keys($this->registry->getProviders())) . ''); + + return ExitCode::Invalid; + } + + try { + $filter = $this->buildFilter($where); + } catch (\InvalidArgumentException $e) { + $output->writeln('' . $e->getMessage() . ''); + + return ExitCode::Invalid; + } + + $rows = []; + try { + foreach ($searchProvider->search($user, $filter, $limit, $offset) as $result) { + if ($detail) { + $result = $searchProvider->get($user, $result->getId()) ?? $result; + } + $rows[] = $this->formatResult($result); + } + } catch (AccountUnavailableException|SearchTruncatedException $e) { + $output->writeln('' . $e->getMessage() . ''); + + return ExitCode::Failure; + } + + if ($rows === []) { + $output->writeln('No results.'); + + return ExitCode::Success; + } + + $output->writeTableInOutputFormat($rows); + + return ExitCode::Success; + } + + /** + * @return array + */ + private function formatResult(AccountScopedSearchResult $result): array { + $row = ['id' => $result->getId(), 'title' => $result->getTitle()]; + foreach ($result->getMetadata() as $name => $value) { + $row[$name] = $this->formatValue($value); + } + foreach ($result->getMetadataErrors() as $name => $reason) { + $row[$name] = '(' . $reason . ')'; + } + + return $row; + } + + /** + * @param list $where + * @throws \InvalidArgumentException on a malformed condition + */ + private function buildFilter(array $where): ?ISearchOperator { + $conditions = []; + foreach ($where as $condition) { + [$field, $value] = array_pad(explode('=', $condition, 2), 2, ''); + if ($field === '') { + throw new \InvalidArgumentException('Invalid --where "' . $condition . '", expected field=value'); + } + $conditions[] = new SearchComparison(ISearchComparison::COMPARE_LIKE, $field, '%' . $this->escapeLike($value) . '%'); + } + + return match (count($conditions)) { + 0 => null, + 1 => $conditions[0], + default => new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, $conditions), + }; + } + + /** + * Escape the LIKE wildcards in a value. + */ + private function escapeLike(string $value): string { + return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); + } + + private function formatValue(mixed $value): string { + return is_scalar($value) ? (string)$value : (json_encode($value) ?: ''); + } +} diff --git a/core/register_command.php b/core/register_command.php index d5f8d76279330..ee9dcc1c03dd7 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -7,6 +7,7 @@ * SPDX-FileCopyrightText: 2013-2016 ownCloud, Inc. * SPDX-License-Identifier: AGPL-3.0-only */ +use OC\Console\CommandAdapter; use OC\Core\Command; use OC\Core\Command\App\Disable; use OC\Core\Command\App\Enable; @@ -89,6 +90,8 @@ use OC\Core\Command\Preview\ResetRenderedTexts; use OC\Core\Command\Router\ListRoutes; use OC\Core\Command\Router\MatchRoute; +use OC\Core\Command\Search\ProvidersCommand; +use OC\Core\Command\Search\QueryCommand; use OC\Core\Command\Security\BruteforceAttempts; use OC\Core\Command\Security\BruteforceResetAttempts; use OC\Core\Command\Security\ExportCertificates; @@ -263,6 +266,8 @@ $application->addCommand(Server::get(ExportCertificates::class)); $application->addCommand(Server::get(ImportCertificate::class)); $application->addCommand(Server::get(RemoveCertificate::class)); + $application->addCommand(new CommandAdapter(ProvidersCommand::class, null, \OC::$server)); + $application->addCommand(new CommandAdapter(QueryCommand::class, null, \OC::$server)); $application->addCommand(Server::get(BruteforceAttempts::class)); $application->addCommand(Server::get(BruteforceResetAttempts::class)); $application->addCommand(Server::get(SetupChecks::class)); diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 70ebb77e1aaa6..aa760e90ec272 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -17,6 +17,13 @@ 'NCU\\Config\\Lexicon\\Preset' => $baseDir . '/lib/unstable/Config/Lexicon/Preset.php', 'NCU\\Config\\ValueType' => $baseDir . '/lib/unstable/Config/ValueType.php', 'NCU\\Federation\\ISignedCloudFederationProvider' => $baseDir . '/lib/unstable/Federation/ISignedCloudFederationProvider.php', + 'NCU\\Search\\AccountScopedSearchResult' => $baseDir . '/lib/unstable/Search/AccountScopedSearchResult.php', + 'NCU\\Search\\Exceptions\\AccountUnavailableException' => $baseDir . '/lib/unstable/Search/Exceptions/AccountUnavailableException.php', + 'NCU\\Search\\Exceptions\\SearchTruncatedException' => $baseDir . '/lib/unstable/Search/Exceptions/SearchTruncatedException.php', + 'NCU\\Search\\IAccountScopedSearchProvider' => $baseDir . '/lib/unstable/Search/IAccountScopedSearchProvider.php', + 'NCU\\Search\\IAccountScopedSearchProviderRegistry' => $baseDir . '/lib/unstable/Search/IAccountScopedSearchProviderRegistry.php', + 'NCU\\Search\\SearchPropertyDefinition' => $baseDir . '/lib/unstable/Search/SearchPropertyDefinition.php', + 'NCU\\Search\\SearchPropertyType' => $baseDir . '/lib/unstable/Search/SearchPropertyType.php', 'NCU\\Security\\Signature\\Enum\\DigestAlgorithm' => $baseDir . '/lib/unstable/Security/Signature/Enum/DigestAlgorithm.php', 'NCU\\Security\\Signature\\Enum\\SignatoryStatus' => $baseDir . '/lib/unstable/Security/Signature/Enum/SignatoryStatus.php', 'NCU\\Security\\Signature\\Enum\\SignatoryType' => $baseDir . '/lib/unstable/Security/Signature/Enum/SignatoryType.php', @@ -1540,6 +1547,8 @@ 'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => $baseDir . '/core/Command/Preview/ResetRenderedTexts.php', 'OC\\Core\\Command\\Router\\ListRoutes' => $baseDir . '/core/Command/Router/ListRoutes.php', 'OC\\Core\\Command\\Router\\MatchRoute' => $baseDir . '/core/Command/Router/MatchRoute.php', + 'OC\\Core\\Command\\Search\\ProvidersCommand' => $baseDir . '/core/Command/Search/ProvidersCommand.php', + 'OC\\Core\\Command\\Search\\QueryCommand' => $baseDir . '/core/Command/Search/QueryCommand.php', 'OC\\Core\\Command\\Security\\BruteforceAttempts' => $baseDir . '/core/Command/Security/BruteforceAttempts.php', 'OC\\Core\\Command\\Security\\BruteforceResetAttempts' => $baseDir . '/core/Command/Security/BruteforceResetAttempts.php', 'OC\\Core\\Command\\Security\\ExportCertificates' => $baseDir . '/core/Command/Security/ExportCertificates.php', @@ -2263,6 +2272,7 @@ 'OC\\Route\\CachingRouter' => $baseDir . '/lib/private/Route/CachingRouter.php', 'OC\\Route\\Route' => $baseDir . '/lib/private/Route/Route.php', 'OC\\Route\\Router' => $baseDir . '/lib/private/Route/Router.php', + 'OC\\Search\\AccountScopedSearchProviderRegistry' => $baseDir . '/lib/private/Search/AccountScopedSearchProviderRegistry.php', 'OC\\Search\\FilterCollection' => $baseDir . '/lib/private/Search/FilterCollection.php', 'OC\\Search\\FilterFactory' => $baseDir . '/lib/private/Search/FilterFactory.php', 'OC\\Search\\Filter\\BooleanFilter' => $baseDir . '/lib/private/Search/Filter/BooleanFilter.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 75746428ed294..85f9f82dd0981 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -58,6 +58,13 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'NCU\\Config\\Lexicon\\Preset' => __DIR__ . '/../../..' . '/lib/unstable/Config/Lexicon/Preset.php', 'NCU\\Config\\ValueType' => __DIR__ . '/../../..' . '/lib/unstable/Config/ValueType.php', 'NCU\\Federation\\ISignedCloudFederationProvider' => __DIR__ . '/../../..' . '/lib/unstable/Federation/ISignedCloudFederationProvider.php', + 'NCU\\Search\\AccountScopedSearchResult' => __DIR__ . '/../../..' . '/lib/unstable/Search/AccountScopedSearchResult.php', + 'NCU\\Search\\Exceptions\\AccountUnavailableException' => __DIR__ . '/../../..' . '/lib/unstable/Search/Exceptions/AccountUnavailableException.php', + 'NCU\\Search\\Exceptions\\SearchTruncatedException' => __DIR__ . '/../../..' . '/lib/unstable/Search/Exceptions/SearchTruncatedException.php', + 'NCU\\Search\\IAccountScopedSearchProvider' => __DIR__ . '/../../..' . '/lib/unstable/Search/IAccountScopedSearchProvider.php', + 'NCU\\Search\\IAccountScopedSearchProviderRegistry' => __DIR__ . '/../../..' . '/lib/unstable/Search/IAccountScopedSearchProviderRegistry.php', + 'NCU\\Search\\SearchPropertyDefinition' => __DIR__ . '/../../..' . '/lib/unstable/Search/SearchPropertyDefinition.php', + 'NCU\\Search\\SearchPropertyType' => __DIR__ . '/../../..' . '/lib/unstable/Search/SearchPropertyType.php', 'NCU\\Security\\Signature\\Enum\\DigestAlgorithm' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Enum/DigestAlgorithm.php', 'NCU\\Security\\Signature\\Enum\\SignatoryStatus' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Enum/SignatoryStatus.php', 'NCU\\Security\\Signature\\Enum\\SignatoryType' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Enum/SignatoryType.php', @@ -1581,6 +1588,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => __DIR__ . '/../../..' . '/core/Command/Preview/ResetRenderedTexts.php', 'OC\\Core\\Command\\Router\\ListRoutes' => __DIR__ . '/../../..' . '/core/Command/Router/ListRoutes.php', 'OC\\Core\\Command\\Router\\MatchRoute' => __DIR__ . '/../../..' . '/core/Command/Router/MatchRoute.php', + 'OC\\Core\\Command\\Search\\ProvidersCommand' => __DIR__ . '/../../..' . '/core/Command/Search/ProvidersCommand.php', + 'OC\\Core\\Command\\Search\\QueryCommand' => __DIR__ . '/../../..' . '/core/Command/Search/QueryCommand.php', 'OC\\Core\\Command\\Security\\BruteforceAttempts' => __DIR__ . '/../../..' . '/core/Command/Security/BruteforceAttempts.php', 'OC\\Core\\Command\\Security\\BruteforceResetAttempts' => __DIR__ . '/../../..' . '/core/Command/Security/BruteforceResetAttempts.php', 'OC\\Core\\Command\\Security\\ExportCertificates' => __DIR__ . '/../../..' . '/core/Command/Security/ExportCertificates.php', @@ -2304,6 +2313,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Route\\CachingRouter' => __DIR__ . '/../../..' . '/lib/private/Route/CachingRouter.php', 'OC\\Route\\Route' => __DIR__ . '/../../..' . '/lib/private/Route/Route.php', 'OC\\Route\\Router' => __DIR__ . '/../../..' . '/lib/private/Route/Router.php', + 'OC\\Search\\AccountScopedSearchProviderRegistry' => __DIR__ . '/../../..' . '/lib/private/Search/AccountScopedSearchProviderRegistry.php', 'OC\\Search\\FilterCollection' => __DIR__ . '/../../..' . '/lib/private/Search/FilterCollection.php', 'OC\\Search\\FilterFactory' => __DIR__ . '/../../..' . '/lib/private/Search/FilterFactory.php', 'OC\\Search\\Filter\\BooleanFilter' => __DIR__ . '/../../..' . '/lib/private/Search/Filter/BooleanFilter.php', diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index 76458e2bf74ad..f86890441243b 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -10,6 +10,7 @@ namespace OC\AppFramework\Bootstrap; use Closure; +use NCU\Search\IAccountScopedSearchProvider; use OC\AppFramework\DependencyInjection\DIContainer; use OC\Support\CrashReport\Registry; use OCP\AppFramework\App; @@ -96,6 +97,9 @@ class RegistrationContext { /** @var ServiceRegistration[] */ private $searchProviders = []; + /** @var ServiceRegistration[] */ + private array $accountScopedSearchProviders = []; + /** @var ServiceRegistration[] */ private $alternativeLogins = []; @@ -266,6 +270,14 @@ public function registerSearchProvider(string $class): void { ); } + #[\Override] + public function registerAccountScopedSearchProvider(string $class): void { + $this->context->registerAccountScopedSearchProvider( + $this->appId, + $class + ); + } + #[\Override] public function registerAlternativeLogin(string $class): void { $this->context->registerAlternativeLogin( @@ -564,6 +576,10 @@ public function registerSearchProvider(string $appId, string $class) { $this->searchProviders[] = new ServiceRegistration($appId, $class); } + public function registerAccountScopedSearchProvider(string $appId, string $class): void { + $this->accountScopedSearchProviders[] = new ServiceRegistration($appId, $class); + } + public function registerAlternativeLogin(string $appId, string $class): void { $this->alternativeLogins[] = new ServiceRegistration($appId, $class); } @@ -923,6 +939,13 @@ public function getSearchProviders(): array { return $this->searchProviders; } + /** + * @return ServiceRegistration[] + */ + public function getAccountScopedSearchProviders(): array { + return $this->accountScopedSearchProviders; + } + /** * @return ServiceRegistration[] */ diff --git a/lib/private/Console/NullOutput.php b/lib/private/Console/NullOutput.php index 5a9443d946b72..3244e8409ab56 100644 --- a/lib/private/Console/NullOutput.php +++ b/lib/private/Console/NullOutput.php @@ -65,6 +65,10 @@ public function writeTableInOutputFormat(array $items): void { public function writeStreamingTableInOutputFormat(\Iterator $items, int $tableGroupSize): void { } + #[Override] + public function writeTree(iterable $nodes, string $root = ''): void { + } + #[Override] public function progressStart(int $max = 0): void { } diff --git a/lib/private/Console/OutputAdapter.php b/lib/private/Console/OutputAdapter.php index c53c01823327f..0df5bccc5192b 100644 --- a/lib/private/Console/OutputAdapter.php +++ b/lib/private/Console/OutputAdapter.php @@ -8,6 +8,7 @@ namespace OC\Console; use OCP\Console\IOutput; +use OCP\Console\OutputFormat; use OCP\Console\Verbosity; use Override; use Symfony\Component\Console\Input\InputInterface; @@ -68,6 +69,34 @@ public function writeStreamingTableInOutputFormat(\Iterator $items, int $tableGr $this->commandAdapter->writeStreamingTableInOutputFormat($this->input, $this->output, $items, $tableGroupSize); } + #[Override] + public function writeTree(iterable $nodes, string $root = ''): void { + $outputFormat = OutputFormat::tryFrom((string)$this->input->getOption('output')) ?? OutputFormat::Plain; + if ($outputFormat === OutputFormat::Plain) { + $this->symfonyStyle->tree($nodes, $root); + + return; + } + + $data = $this->toArray($nodes); + $this->output->writeln(json_encode( + $root !== '' ? [$root => $data] : $data, + $outputFormat === OutputFormat::JsonPretty ? JSON_PRETTY_PRINT : 0, + )); + } + + /** + * @return array + */ + private function toArray(iterable $nodes): array { + $result = []; + foreach ($nodes as $key => $value) { + $result[$key] = is_iterable($value) ? $this->toArray($value) : $value; + } + + return $result; + } + #[Override] public function setVerbosity(Verbosity $level): void { $this->output->setVerbosity($level->value); diff --git a/lib/private/Search/AccountScopedSearchProviderRegistry.php b/lib/private/Search/AccountScopedSearchProviderRegistry.php new file mode 100644 index 0000000000000..9baf2d3fdf62c --- /dev/null +++ b/lib/private/Search/AccountScopedSearchProviderRegistry.php @@ -0,0 +1,74 @@ +|null */ + private ?array $providers = null; + + public function __construct( + private readonly Coordinator $coordinator, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + } + + #[\Override] + public function getProviders(): array { + return $this->load(); + } + + #[\Override] + public function getProvider(string $id): ?IAccountScopedSearchProvider { + return $this->load()[$id] ?? null; + } + + /** + * @return array + */ + private function load(): array { + if ($this->providers !== null) { + return $this->providers; + } + + $context = $this->coordinator->getRegistrationContext(); + if ($context === null) { + // Not cached: nothing is registered yet this early in boot. + return []; + } + + $providers = []; + foreach ($context->getAccountScopedSearchProviders() as $registration) { + try { + /** @var IAccountScopedSearchProvider $provider */ + $provider = $this->container->get($registration->getService()); + } catch (Throwable $e) { + $this->logger->error('Could not load an account-scoped search provider', [ + 'exception' => $e, + 'app' => $registration->getAppId(), + ]); + + continue; + } + $providers[$provider->getId()] = $provider; + } + + $this->providers = $providers; + + return $providers; + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index ed0ba5c04f741..2f960acfb09c4 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -108,6 +108,7 @@ use OC\RichObjectStrings\Validator; use OC\Route\CachingRouter; use OC\Route\Router; +use OC\Search\AccountScopedSearchProviderRegistry; use OC\Security\Bruteforce\Capabilities; use OC\Security\Bruteforce\Throttler; use OC\Security\CertificateManager; @@ -1104,6 +1105,8 @@ public function __construct( $this->registerAlias(\NCU\Sharing\ISharingManager::class, SharingManager::class); $this->registerAlias(\NCU\Sharing\ISharingBackend::class, SharingBackend::class); + $this->registerAlias(\NCU\Search\IAccountScopedSearchProviderRegistry::class, AccountScopedSearchProviderRegistry::class); + $this->registerService(IGlobalScaleService::class, static function (ContainerInterface $c): IGlobalScaleService { /** @var Coordinator $coordinator */ $coordinator = $c->get(Coordinator::class); diff --git a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php index cac28bc69dadd..6609c4747051e 100644 --- a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php +++ b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php @@ -150,6 +150,22 @@ public function registerMiddleware(string $class, bool $global = false): void; */ public function registerSearchProvider(string $class): void; + /** + * Register an account-scoped search provider + * + * Unlike a unified search provider, this searches on behalf of an account passed explicitly + * rather than the calling session, and returns structured metadata rather than display strings. + * `\NCU\Search\IAccountScopedSearchProvider` is currently experimental and may still change. + * + * @param string $class + * @psalm-param class-string<\NCU\Search\IAccountScopedSearchProvider> $class + * + * @return void + * + * @since 36.0.0 + */ + public function registerAccountScopedSearchProvider(string $class): void; + /** * Register an alternative login option * diff --git a/lib/public/Console/IOutput.php b/lib/public/Console/IOutput.php index 5629caa86b1d8..e70ad39a4eece 100644 --- a/lib/public/Console/IOutput.php +++ b/lib/public/Console/IOutput.php @@ -85,6 +85,16 @@ public function writeArrayInOutputFormat(iterable $items, string $prefix = ' - */ public function writeTableInOutputFormat(array $items): void; + /** + * Write a tree, in the format specified with --output. A value that is itself iterable becomes + * a labelled branch, rendered under a key that names it; anything else becomes a leaf, rendered + * as-is. For JSON, this is the nested structure itself, wrapped under $root when it is not empty. + * + * @param iterable $nodes + * @since 36.0.0 + */ + public function writeTree(iterable $nodes, string $root = ''): void; + /** * Write a multidimensional iterator of items in the format specified with --output * diff --git a/lib/unstable/Search/AccountScopedSearchResult.php b/lib/unstable/Search/AccountScopedSearchResult.php new file mode 100644 index 0000000000000..1b9d995e9a530 --- /dev/null +++ b/lib/unstable/Search/AccountScopedSearchResult.php @@ -0,0 +1,63 @@ + */ + private array $metadata = []; + + /** @var array */ + private array $metadataErrors = []; + + public function __construct( + /** Source-native, stable across renames and moves. */ + private readonly string $id, + private readonly string $title, + ) { + } + + public function getId(): string { + return $this->id; + } + + public function getTitle(): string { + return $this->title; + } + + public function setMetadata(string $name, mixed $value): void { + $this->metadata[$name] = $value; + unset($this->metadataErrors[$name]); + } + + /** + * Record that a property could not be read, and why. + */ + public function setMetadataError(string $name, string $reason): void { + $this->metadataErrors[$name] = $reason; + unset($this->metadata[$name]); + } + + /** + * @return array + */ + public function getMetadata(): array { + return $this->metadata; + } + + /** + * @return array Property name => why it could not be read + */ + public function getMetadataErrors(): array { + return $this->metadataErrors; + } +} diff --git a/lib/unstable/Search/Exceptions/AccountUnavailableException.php b/lib/unstable/Search/Exceptions/AccountUnavailableException.php new file mode 100644 index 0000000000000..7051e2de90f88 --- /dev/null +++ b/lib/unstable/Search/Exceptions/AccountUnavailableException.php @@ -0,0 +1,18 @@ + + */ + public function getProperties(): array; + + /** + * Search one account's data. + * + * The results are yielded in an order chosen by the provider that is total and stable, so a + * caller can page through them with the offset and limit. + * + * @param non-empty-string $userId + * @param ISearchOperator|null $filter Comparisons on the searchable properties, or null for everything + * @return \Generator Each result contains the selectable properties + * from {@see getProperties()} that are not detail-only + * @throws AccountUnavailableException when the account's data cannot be read at all + * @throws SearchTruncatedException when the search cannot be answered exhaustively + */ + public function search( + string $userId, + ?ISearchOperator $filter, + int $limit, + int $offset = 0, + ): \Generator; + + /** + * The current state of one item, as seen by one account. + * + * @param non-empty-string $userId + * @param string $id An id previously returned by {@see search()} + * @return AccountScopedSearchResult|null The item with every selectable property from + * {@see getProperties()} in its metadata, or null when it + * no longer exists or is not visible to the account + * @throws AccountUnavailableException when the account's data cannot be read at all + */ + public function get(string $userId, string $id): ?AccountScopedSearchResult; + + /** + * The content of one item, as seen by one account. + * + * May be evidence under legal hold: never call the returned file's mutating methods. + * + * @param non-empty-string $userId + * @param string $id An id previously returned by {@see search()} + * @return ISimpleFile|null null when the item no longer exists or is not visible to the account + * @throws AccountUnavailableException when the account's data cannot be read at all + */ + public function readContent(string $userId, string $id): ?ISimpleFile; +} diff --git a/lib/unstable/Search/IAccountScopedSearchProviderRegistry.php b/lib/unstable/Search/IAccountScopedSearchProviderRegistry.php new file mode 100644 index 0000000000000..b44d45a1db869 --- /dev/null +++ b/lib/unstable/Search/IAccountScopedSearchProviderRegistry.php @@ -0,0 +1,32 @@ + keyed by getId() + * @experimental 36.0.0 + */ + public function getProviders(): array; + + /** + * @experimental 36.0.0 + */ + public function getProvider(string $id): ?IAccountScopedSearchProvider; +} diff --git a/lib/unstable/Search/SearchPropertyDefinition.php b/lib/unstable/Search/SearchPropertyDefinition.php new file mode 100644 index 0000000000000..692976be253ab --- /dev/null +++ b/lib/unstable/Search/SearchPropertyDefinition.php @@ -0,0 +1,68 @@ +name; + } + + public function getTitle(): string { + return $this->title; + } + + public function getType(): SearchPropertyType { + return $this->type; + } + + public function isSearchable(): bool { + return $this->searchable; + } + + public function isSelectable(): bool { + return $this->selectable; + } + + public function isDetailOnly(): bool { + return $this->detailOnly; + } + + public function isIndexed(): bool { + return $this->indexed; + } +} diff --git a/lib/unstable/Search/SearchPropertyType.php b/lib/unstable/Search/SearchPropertyType.php new file mode 100644 index 0000000000000..81f78f71f2d43 --- /dev/null +++ b/lib/unstable/Search/SearchPropertyType.php @@ -0,0 +1,25 @@ +