diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 310f1ec83be3d..edeb2b5dfb6d3 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\\AccountScopedSearchProvider' => $baseDir . '/../lib/CalDAV/Search/AccountScopedSearchProvider.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\\AccountScopedSearchProvider' => $baseDir . '/../lib/CardDAV/Search/AccountScopedSearchProvider.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..37c7e34941620 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\\AccountScopedSearchProvider' => __DIR__ . '/..' . '/../lib/CalDAV/Search/AccountScopedSearchProvider.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\\AccountScopedSearchProvider' => __DIR__ . '/..' . '/../lib/CardDAV/Search/AccountScopedSearchProvider.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..e165a7f6d6ad9 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\AccountScopedSearchProvider as 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\AccountScopedSearchProvider as ContactsAccountScopedSearchProvider; 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(ContactsAccountScopedSearchProvider::class); + $context->registerAccountScopedSearchProvider(CalendarAccountScopedSearchProvider::class); + /** * Register event listeners */ diff --git a/apps/dav/lib/CalDAV/Search/AccountScopedSearchProvider.php b/apps/dav/lib/CalDAV/Search/AccountScopedSearchProvider.php new file mode 100644 index 0000000000000..4b295a773137c --- /dev/null +++ b/apps/dav/lib/CalDAV/Search/AccountScopedSearchProvider.php @@ -0,0 +1,524 @@ + self::CONTENT_PROPERTIES, + 'name' => ['SUMMARY'], + 'attendee' => ['ATTENDEE'], + 'organizer' => ['ORGANIZER'], + ]; + + private const COMPONENT_TYPES = ['VEVENT', 'VTODO', 'VJOURNAL']; + + /** + * Generated from contacts rather than entered by anyone, so collecting it would duplicate the + * Contacts provider and present derived data as a calendar record. + */ + private const GENERATED_CALENDAR_URIS = ['contact_birthdays']; + + public function __construct( + private readonly IL10N $l10n, + private readonly IManager $calendarManager, + 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 getSearchCriterion(): array { + return [ + ['id' => 'content', 'title' => $this->l10n->t('Content'), 'type' => 'text', 'default' => ''], + ['id' => 'name', 'title' => $this->l10n->t('Title'), 'type' => 'text', 'default' => ''], + ['id' => 'attendee', 'title' => $this->l10n->t('Attendee'), 'type' => 'text', 'default' => ''], + ['id' => 'organizer', 'title' => $this->l10n->t('Organizer'), 'type' => 'text', 'default' => ''], + // When the meeting happens, not when the record was written — CalDAV exposes no filter + // on the latter at all. + ['id' => 'event_start', 'title' => $this->l10n->t('Starts on or after'), 'type' => 'number', 'default' => 0], + ['id' => 'event_end', 'title' => $this->l10n->t('Ends on or before'), 'type' => 'number', 'default' => 0], + ]; + } + + #[\Override] + public function getSearchResultMetadataKeys(): array { + return [ + 'owner' => $this->l10n->t('Owner'), + 'calendarName' => $this->l10n->t('Calendar'), + 'uid' => $this->l10n->t('UID'), + 'shared' => $this->l10n->t('Shared'), + 'checksum' => $this->l10n->t('Checksum'), + 'eventStart' => $this->l10n->t('Starts'), + 'eventEnd' => $this->l10n->t('Ends'), + 'organizer' => $this->l10n->t('Organizer'), + 'attendees' => $this->l10n->t('Attendees'), + 'location' => $this->l10n->t('Location'), + ]; + } + + #[\Override] + public function search(string $userId, ISearchQuery $query): \Generator { + $operation = $query->getSearchOperation(); + $options = $this->timerangeOptions($operation); + + $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; + } + + // The same UID can recur within one account — scheduling copies an invitation into + // every attendee's own calendar, and recurrence expansion repeats it per occurrence. + // Either way it is one meeting, not several. + if (isset($seen[$uid])) { + continue; + } + $seen[$uid] = true; + + if (!SearchOperatorEvaluator::matches($operation, fn (string $field): array => $this->valuesFor($object, $field))) { + continue; + } + + // Skipped after matching, because the offset counts matches, not candidates. Exact + // because candidate order is sorted in candidates() rather than left to the backend. + if ($skipped < $query->getOffset()) { + $skipped++; + + continue; + } + if ($yielded >= $query->getLimit()) { + return; + } + $yielded++; + + $summary = $this->firstString($object, 'SUMMARY'); + $entry = new AccountScopedSearchResult( + $this->encodeId($calendar->getUri(), $uid), + $summary !== '' ? $summary : ('Untitled ' . strtolower((string)($object['type'] ?? 'event'))), + ); + + $this->addMetaData($entry, 'owner', static fn (): string => $userId); + $this->addMetaData($entry, 'calendarName', 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); + + $start = $this->firstTimestamp($object, 'DTSTART'); + $this->addMetaData($entry, 'eventStart', static function () use ($start): int { + if ($start === null) { + throw new \RuntimeException('this object carries no DTSTART'); + } + + return $start; + }); + + $end = $this->firstTimestamp($object, 'DTEND') ?? $start; + $this->addMetaData($entry, 'eventEnd', static function () use ($end): int { + if ($end === null) { + throw new \RuntimeException('this object carries no DTEND'); + } + + return $end; + }); + + $ics = $this->exportObject($calendar, $uid); + $this->addMetaData($entry, 'checksum', static function () use ($ics): string { + if ($ics === null) { + throw new \RuntimeException('calendar object not found while computing a checksum'); + } + + // Hashed here since there is nothing stored to read; re-serialising the same blob + // is deterministic, so this is reproducible at export time. + return 'SHA256:' . hash('sha256', $ics); + }); + + $attributes = $ics === null ? null : $this->attributesOf($ics); + $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, 'attendees', 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']; + }); + + yield $entry; + } + } + } + + private function addMetaData(AccountScopedSearchResult $entry, string $name, callable $read): void { + try { + $value = $read(); + $entry->addMetaData($value === null + ? MetadataField::notCaptured($name, 'not reported by the storage') + : MetadataField::captured($name, $value)); + } catch (\Throwable $e) { + $entry->addMetaData(MetadataField::notCaptured($name, $e->getMessage())); + } + } + + #[\Override] + public function readContent(string $userId, AccountScopedSearchResult $entry): ?ISimpleFile { + $decoded = $this->decodeId($entry->getId()); + if ($decoded === null) { + return null; + } + [$calendarUri, $uid] = $decoded; + + $calendar = $this->findCalendar($userId, $calendarUri); + $ics = $calendar === null ? null : $this->exportObject($calendar, $uid); + if ($ics === null) { + return null; + } + + return new InMemoryFile($this->safeName($uid) . '.ics', $ics); + } + + /** + * The facts about a meeting that are not its text: who called it, who was invited, where. + * + * @return array + */ + private function attributesOf(string $ics): array { + $attributes = []; + + try { + $parsed = Reader::read($ics); + } catch (\Throwable $e) { + $this->logger->debug('Could not parse a calendar object for its attributes', ['exception' => $e]); + + return $attributes; + } + + $component = null; + foreach ($parsed->getComponents() as $candidate) { + if (in_array($candidate->name, ['VEVENT', 'VTODO'], true)) { + $component = $candidate; + break; + } + } + if ($component === null) { + return $attributes; + } + + // "CN
", because a name is what someone types into a search box and an address is + // what identifies the person. Both, or whichever is there. + $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 — attendees, alarms and recurrence rules + * intact, not a reconstruction from search results. + * + * `export()` has no uid filter, so this is a linear scan of the calendar per item. Fine for a + * personal calendar, worth caching per calendar per call if it ever shows up as a hot path. + */ + private function exportObject(ICalendar $calendar, string $uid): ?string { + if (!$calendar instanceof ICalendarExport) { + return null; + } + + try { + foreach ($calendar->export(null) as $vCalendar) { + if ($this->uidOf($vCalendar) === $uid) { + return $vCalendar->serialize(); + } + } + } catch (\Throwable $e) { + $this->logger->warning('Calendar export failed', ['exception' => $e, 'uid' => $uid]); + } + + return null; + } + + /** + * Identity is the UID, not the DAV URI: it is the object's own identity, a client may PUT an + * object at any URI it likes, and every expanded occurrence of a recurrence carries it. + */ + 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 — otherwise + * deliberately not narrowed by the property index, see `SearchOperatorEvaluator`. The time + * range narrows soundly because it comes from the `DTSTART`/`DTEND` columns, not that index. + * + * @param array $options + * @return list> + */ + private function candidates(ICalendar $calendar, array $options): array { + try { + $page = $calendar->search('', [], $options, null); + } catch (\Throwable $e) { + // An unreadable calendar is not a reason to abandon the rest of the account. + $this->logger->warning('Calendar search failed', ['exception' => $e, 'calendar' => $calendar->getUri()]); + + return []; + } + + $found = []; + foreach ($page as $object) { + $found[(string)($object['uid'] ?? '')] = $object; + } + + // Ordered by UID, so resuming a search skips the same matches an interrupted call already + // yielded. CalDAV promises no order of its own. + 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 + // ICalendarExport excludes federated calendars, which are read-only mirrors of records + // held elsewhere and cannot be exported as evidence anyway. + => $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 hands back `[value, parameters]` for a property that may occur once and a list of + * those for one that may repeat; `is_array($raw[0])` tells them apart, because a single value + * is always a string or a date. + * + * @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/AccountScopedSearchProvider.php b/apps/dav/lib/CardDAV/Search/AccountScopedSearchProvider.php new file mode 100644 index 0000000000000..7aef1fa585b16 --- /dev/null +++ b/apps/dav/lib/CardDAV/Search/AccountScopedSearchProvider.php @@ -0,0 +1,407 @@ + 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 LoggerInterface $logger, + ) { + } + + #[\Override] + public function getId(): string { + return self::ID; + } + + #[\Override] + public function getName(): string { + return $this->l10n->t('Contacts'); + } + + #[\Override] + public function getSearchCriterion(): array { + return [ + ['id' => 'content', 'title' => $this->l10n->t('Content'), 'type' => 'text', 'default' => ''], + ['id' => 'name', 'title' => $this->l10n->t('Name'), 'type' => 'text', 'default' => ''], + ['id' => 'email', 'title' => $this->l10n->t('Email'), 'type' => 'text', 'default' => ''], + ['id' => 'phone', 'title' => $this->l10n->t('Phone'), 'type' => 'text', 'default' => ''], + ['id' => 'organisation', 'title' => $this->l10n->t('Organisation'), 'type' => 'text', 'default' => ''], + ]; + } + + #[\Override] + public function getSearchResultMetadataKeys(): array { + return [ + 'owner' => $this->l10n->t('Owner'), + 'addressBookName' => $this->l10n->t('Address book'), + 'modified' => $this->l10n->t('Modified'), + 'checksum' => $this->l10n->t('Checksum'), + 'shared' => $this->l10n->t('Shared'), + 'emails' => $this->l10n->t('Emails'), + 'phones' => $this->l10n->t('Phones'), + 'organisation' => $this->l10n->t('Organisation'), + 'jobTitle' => $this->l10n->t('Job title'), + 'categories' => $this->l10n->t('Categories'), + ]; + } + + #[\Override] + public function search(string $userId, ISearchQuery $query): \Generator { + $books = $this->addressBooksFor($userId); + $operation = $query->getSearchOperation(); + + $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 === '') { + // A hit in the system address book, or one whose book was filtered out. + continue; + } + + $card = $this->parse((string)($row['carddata'] ?? '')); + if ($card === null) { + continue; + } + + if (!SearchOperatorEvaluator::matches($operation, fn (string $field): array => $this->valuesFor($card, $field))) { + continue; + } + + // Skipped after matching, because the offset counts matches, not candidates. Exact + // because candidate order is sorted in candidates() rather than left to the backend. + if ($skipped < $query->getOffset()) { + $skipped++; + + continue; + } + if ($yielded >= $query->getLimit()) { + return; + } + $yielded++; + + $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, 'addressBookName', 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() streams. CardDAV keeps no checksum + // of its own, so this is computed here rather than read. + $data = (string)($row['carddata'] ?? ''); + $this->addMetaData($entry, 'checksum', static fn (): string => 'SHA256:' . hash('sha256', $data)); + + $this->addMetaData($entry, 'emails', fn (): array => $this->propertyValues($card, 'EMAIL')); + $this->addMetaData($entry, 'phones', 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, 'jobTitle', static function () use ($jobTitle): string { + if ($jobTitle === []) { + throw new \RuntimeException('no job title recorded on this card'); + } + + return $jobTitle[0]; + }); + + yield $entry; + } + } + + /** + * Attach one field, or record why it could not be read. A read failure never drops the whole + * entry — the search result is still yielded and the gap is visible on the field itself. + */ + private function addMetaData(AccountScopedSearchResult $entry, string $name, callable $read): void { + try { + $value = $read(); + $entry->addMetaData($value === null + ? MetadataField::notCaptured($name, 'not reported by the storage') + : MetadataField::captured($name, $value)); + } catch (\Throwable $e) { + $entry->addMetaData(MetadataField::notCaptured($name, $e->getMessage())); + } + } + + #[\Override] + public function readContent(string $userId, AccountScopedSearchResult $entry): ?ISimpleFile { + $decoded = $this->decodeId($entry->getId()); + if ($decoded === null) { + return null; + } + [$bookUri, $cardUri] = $decoded; + + $book = $this->findAddressBook($userId, $bookUri); + $data = $book === null ? null : $this->cardData((int)$book['id'], $cardUri); + if ($data === null) { + return null; + } + + $card = $this->parse($data); + $name = $card === null ? '' : (string)($card->FN ?? ''); + + return new InMemoryFile($this->safeName($name !== '' ? $name : $cardUri) . '.vcf', $data); + } + + /** + * Every card in the account's address books, deliberately not narrowed by the property index — + * see the class docblock on `SearchOperatorEvaluator`. + * + * @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; + } + + // Ordered by the card's own identity, so resuming an account skips the same matches an + // interrupted call already yielded. CardDAV promises no order of its own. + 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..ddd6b77e8cd36 --- /dev/null +++ b/apps/dav/lib/Search/SearchOperatorEvaluator.php @@ -0,0 +1,230 @@ + $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: `%` becomes "anything", `_` becomes "one character", and + * `\%`/`\_`/`\\` are the literal characters, escaped for the regex like everything else. + */ + 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 — + * CalDAV's `event_start`/`event_end` are the current use, since unlike free text they are + * indexed at full width and narrowing by them is sound. + * + * Only conditions conjoined all the way up to the root qualify: under an `or` or a `not`, the + * bound constrains one branch rather than the whole result, so using it to narrow the backend + * query would drop items that matched some other branch. + */ + 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..4159845605352 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\\AccountScopedSearchProvider' => $baseDir . '/../lib/Search/AccountScopedSearchProvider.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..827123749ddb8 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\\AccountScopedSearchProvider' => __DIR__ . '/..' . '/../lib/Search/AccountScopedSearchProvider.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..cc7bf8c71c72c 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\AccountScopedSearchProvider; 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(AccountScopedSearchProvider::class); $context->registerNotifierService(Notifier::class); $context->registerDashboardWidget(FavoriteWidget::class); diff --git a/apps/files/lib/Search/AccountScopedSearchProvider.php b/apps/files/lib/Search/AccountScopedSearchProvider.php new file mode 100644 index 0000000000000..6fa58fafc54f9 --- /dev/null +++ b/apps/files/lib/Search/AccountScopedSearchProvider.php @@ -0,0 +1,419 @@ +l10n->t('Files'); + } + + #[\Override] + public function getSearchCriterion(): array { + $fields = [ + ['id' => 'name', 'title' => $this->l10n->t('Name'), 'type' => 'text', 'default' => ''], + ['id' => 'path', 'title' => $this->l10n->t('Path'), 'type' => 'text', 'default' => ''], + ['id' => 'mimetype', 'title' => $this->l10n->t('File type'), 'type' => 'text', 'default' => ''], + ['id' => 'size', 'title' => $this->l10n->t('Size'), 'type' => 'number', 'default' => 0], + ['id' => 'modified', 'title' => $this->l10n->t('Modified'), 'type' => 'number', 'default' => 0], + ['id' => 'created', 'title' => $this->l10n->t('Created'), 'type' => 'number', 'default' => 0], + ]; + + // Offered only when an index can actually answer it. Advertising `content` without one + // would let a caller build a search that quietly matches nothing. + if ($this->indexAvailable()) { + $fields[] = ['id' => 'content', 'title' => $this->l10n->t('Content'), 'type' => 'text', 'default' => '']; + } + + return $fields; + } + + /** + * Checked per call rather than cached: an administrator can install or remove the index without + * restarting anything, and a search that claims to have read content when it did not is the + * worst answer this can give. + */ + 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 getSearchResultMetadataKeys(): array { + return [ + 'owner' => $this->l10n->t('Owner'), + 'path' => $this->l10n->t('Path'), + 'mimetype' => $this->l10n->t('File type'), + 'size' => $this->l10n->t('Size'), + 'modified' => $this->l10n->t('Modified'), + 'created' => $this->l10n->t('Created'), + 'checksum' => $this->l10n->t('Checksum'), + 'share_status' => $this->l10n->t('Share status'), + 'sharedWith' => $this->l10n->t('Shared with'), + 'tags' => $this->l10n->t('Tags'), + ]; + } + + #[\Override] + public function search(string $userId, ISearchQuery $query): \Generator { + $userFolder = $this->rootFolder->getUserFolder($userId); + + $contentMatches = $this->resolveContentTerms($query->getSearchOperation(), $userId, []); + $resolvedQuery = new SearchQuery( + $this->substituteContent($query->getSearchOperation(), $contentMatches), + $query->getLimit(), + $query->getOffset(), + $query->getOrder(), + $query->getUser(), + $query->limitToHome(), + array_unique([...$query->getSelectFields(), 'creation_time']), + ); + + foreach ($userFolder->search($resolvedQuery) as $node) { + $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; + }); + + // Nextcloud stores checksums as "TYPE:VALUE" and only when a client supplied one on + // upload; there is no stored SHA-256. + $this->addMetaData($entry, 'checksum', static function () use ($node): string { + $checksum = $node instanceof File ? $node->getChecksum() : ''; + if ($checksum === '') { + throw new \RuntimeException('no checksum stored for this file'); + } + + return $checksum; + }); + + $shareStatus = null; + $this->addMetaData($entry, 'share_status', function () use ($node, &$shareStatus): array { + $shareStatus = $this->shareStatus($node); + + return $shareStatus; + }); + $this->addMetaData($entry, 'sharedWith', 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)); + + yield $entry; + } + } + + /** + * Every distinct `content` term in a tree, resolved once each — the same term can appear more + * than once (e.g. under both branches of an `or`), and each occurrence means the same set, so + * resolving it twice would be an identical Fulltextsearch round-trip for no reason. + * + * @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 = (string)$operator->getValue(); + if (!array_key_exists($term, $resolved)) { + $resolved[$term] = $this->contentMatchedFileIds($term, $userId); + } + } + + return $resolved; + } + + /** + * Rewrite every `content` comparison in a tree into the fileids it resolved to. + * + * @param array> $contentMatches + */ + private function substituteContent(ISearchOperator $operator, array $contentMatches): ISearchOperator { + if ($operator instanceof ISearchBinaryOperator) { + return new SearchBinaryOperator( + $operator->getType(), + array_map(fn (ISearchOperator $child): ISearchOperator + => $this->substituteContent($child, $contentMatches), $operator->getArguments()), + ); + } + + if ($operator instanceof ISearchComparison && $operator->getField() === 'content') { + return $this->fileIdsToComparison($contentMatches[(string)$operator->getValue()]); + } + + return $operator; + } + + /** + * Fileids the index says contain one term, for one account. + * + * @return list + */ + 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) { + // An index that cannot answer must not silently narrow the search to nothing while the + // caller reads a result that looks complete. + 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; + } + } + + // Answering *partially* is just as wrong as not answering: the id set would then be a + // subset, and matching on it would silently discard files that genuinely contain the term. + // ISearchResult exposes no total, so a full page is the only signal available. + if (count($fileIds) >= self::CONTENT_MATCH_LIMIT) { + throw new \RuntimeException( + '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); + } + + $chunks = array_map( + static fn (array $chunk): ISearchOperator + => new SearchComparison(ISearchComparison::COMPARE_IN, 'fileid', array_values($chunk)), + array_chunk($ids, self::ID_CHUNK), + ); + + return count($chunks) === 1 + ? $chunks[0] + : new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_OR, $chunks); + } + + private function addMetaData(AccountScopedSearchResult $entry, string $name, callable $read): void { + try { + $value = $read(); + $entry->addMetaData($value === null + ? MetadataField::notCaptured($name, 'not reported by the storage') + : MetadataField::captured($name, $value)); + } catch (\Throwable $e) { + $entry->addMetaData(MetadataField::notCaptured($name, $e->getMessage())); + } + } + + /** + * @return array{shared: bool, externally: bool, shares: list} + */ + private function shareStatus(Node $node): array { + $owner = $node->getOwner()?->getUID(); + if ($owner === null) { + throw new \RuntimeException('owner unknown, so shares cannot be enumerated'); + } + + $entries = []; + foreach ([...self::EXTERNAL_SHARE_TYPES, IShare::TYPE_USER, IShare::TYPE_GROUP] as $shareType) { + $offset = 0; + do { + // Paged rather than one page: a file shared with more people than fit in one page + // would otherwise record only some of them and assert a share list that is quietly + // incomplete. + $page = $this->shareManager->getSharesBy($owner, $shareType, $node, true, self::SHARE_PAGE, $offset); + foreach ($page as $share) { + $entries[] = [ + 'type' => $shareType, + 'external' => in_array($shareType, self::EXTERNAL_SHARE_TYPES, true), + 'recipient' => $share->getSharedWith(), + // Deliberately NOT the token: a public-link token is a bearer credential, and + // this entry may end up somewhere longer-lived than the search response. + 'shareId' => $share->getId(), + ]; + } + $offset += count($page); + } while (count($page) === self::SHARE_PAGE); + } + + return [ + 'shared' => $entries !== [], + 'externally' => array_filter($entries, static fn (array $e): bool => $e['external']) !== [], + 'shares' => $entries, + ]; + } + + /** + * The tags on this file that anyone is allowed to see. An invisible tag (`userVisible=false`) is + * hidden by the platform from everyone but administrators, and must stay that way here too. + * + * @return list + */ + private function visibleTags(Node $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; + } + + #[\Override] + public function readContent(string $userId, AccountScopedSearchResult $entry): ?ISimpleFile { + $id = $entry->getId(); + if (!ctype_digit($id)) { + return null; + } + + try { + $node = $this->rootFolder->getUserFolder($userId)->getFirstNodeById((int)$id); + } catch (\Throwable) { + return null; + } + + if (!$node instanceof File) { + return null; + } + + return new SimpleFile($node); + } +} diff --git a/core/Command/Search/ProvidersCommand.php b/core/Command/Search/ProvidersCommand.php new file mode 100644 index 0000000000000..5361c917624b5 --- /dev/null +++ b/core/Command/Search/ProvidersCommand.php @@ -0,0 +1,54 @@ +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->getSearchCriterion() as $field) { + $fields[] = $field['id'] . ' — ' . $field['title'] + . ' [' . $field['type'] . ', default=' . $this->formatValue($field['default'] ?? '') . ']'; + } + $tree[$id . ' (' . $provider->getName() . ')'] = $fields; + } + + $output->writeTree($tree, 'Account-scoped search providers'); + + return ExitCode::Success; + } + + private function formatValue(mixed $value): string { + return is_scalar($value) ? (string)$value : (json_encode($value) ?: ''); + } +} diff --git a/core/Command/Search/QueryCommand.php b/core/Command/Search/QueryCommand.php new file mode 100644 index 0000000000000..1ec30aab41a26 --- /dev/null +++ b/core/Command/Search/QueryCommand.php @@ -0,0 +1,122 @@ +registry->getProvider($provider); + if ($searchProvider === null) { + $output->writeln('No such provider "' . $provider . '". Registered: ' + . implode(', ', array_keys($this->registry->getProviders())) . ''); + + return ExitCode::Invalid; + } + + $operation = $this->buildOperation($where, $output); + if ($operation === null) { + return ExitCode::Invalid; + } + + $query = new SearchQuery($operation, $limit, $offset, []); + + $rows = []; + foreach ($searchProvider->search($user, $query) as $result) { + $row = ['id' => $result->getId(), 'title' => $result->getTitle()]; + foreach ($result->getMetaData() as $field) { + $row[$field->getName()] = $field->getStatus() === MetadataFieldStatus::Captured + ? $this->formatValue($field->getValue()) + : '(' . $field->getStatus()->value . ')'; + } + $rows[] = $row; + } + + if ($rows === []) { + $output->writeln('No results.'); + + return ExitCode::Success; + } + + $output->writeTableInOutputFormat($rows); + + return ExitCode::Success; + } + + /** + * @param list $where + */ + private function buildOperation(array $where, IOutput $output): ?ISearchOperator { + $conditions = []; + foreach ($where as $condition) { + [$field, $value] = array_pad(explode('=', $condition, 2), 2, ''); + if ($field === '') { + $output->writeln('Invalid --where "' . $condition . '", expected field=value'); + + return null; + } + $conditions[] = new SearchComparison(ISearchComparison::COMPARE_LIKE, $field, '%' . $this->escapeLike($value) . '%'); + } + + return match (count($conditions)) { + 0 => new SearchComparison(ISearchComparison::COMPARE_LIKE, 'name', '%'), + 1 => $conditions[0], + default => new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, $conditions), + }; + } + + /** + * The wildcards are ours to add, so a value containing one must not act as one. + */ + 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 358217355dae4..c6aa02b7b7023 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -17,6 +17,11 @@ '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\\IAccountScopedSearchProvider' => $baseDir . '/lib/unstable/Search/IAccountScopedSearchProvider.php', + 'NCU\\Search\\IAccountScopedSearchProviderRegistry' => $baseDir . '/lib/unstable/Search/IAccountScopedSearchProviderRegistry.php', + 'NCU\\Search\\MetadataField' => $baseDir . '/lib/unstable/Search/MetadataField.php', + 'NCU\\Search\\MetadataFieldStatus' => $baseDir . '/lib/unstable/Search/MetadataFieldStatus.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', @@ -1541,6 +1546,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', @@ -2264,6 +2271,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 3764598341acf..1444ab1615cc9 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -58,6 +58,11 @@ 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\\IAccountScopedSearchProvider' => __DIR__ . '/../../..' . '/lib/unstable/Search/IAccountScopedSearchProvider.php', + 'NCU\\Search\\IAccountScopedSearchProviderRegistry' => __DIR__ . '/../../..' . '/lib/unstable/Search/IAccountScopedSearchProviderRegistry.php', + 'NCU\\Search\\MetadataField' => __DIR__ . '/../../..' . '/lib/unstable/Search/MetadataField.php', + 'NCU\\Search\\MetadataFieldStatus' => __DIR__ . '/../../..' . '/lib/unstable/Search/MetadataFieldStatus.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', @@ -1582,6 +1587,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', @@ -2305,6 +2312,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..dc2f6ebe710ae --- /dev/null +++ b/lib/private/Search/AccountScopedSearchProviderRegistry.php @@ -0,0 +1,80 @@ +|null */ + private ?array $providers = null; + + public function __construct( + private readonly Coordinator $coordinator, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + } + + #[\Override] + public function clear(): void { + $this->providers = null; + } + + #[\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) { + // Too early, nothing registered yet — not cached, so a later call once boot has + // finished still sees the real list. + 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 9e9e1ea0b5114..a1b3c7b93d30f 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -109,6 +109,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; @@ -1097,6 +1098,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..861628241eb95 --- /dev/null +++ b/lib/unstable/Search/AccountScopedSearchResult.php @@ -0,0 +1,49 @@ + */ + private array $metadata = []; + + 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 addMetaData(MetadataField $field): void { + $this->metadata[] = $field; + } + + /** + * @return list + */ + public function getMetaData(): array { + return $this->metadata; + } +} diff --git a/lib/unstable/Search/IAccountScopedSearchProvider.php b/lib/unstable/Search/IAccountScopedSearchProvider.php new file mode 100644 index 0000000000000..3a59aea46c3c4 --- /dev/null +++ b/lib/unstable/Search/IAccountScopedSearchProvider.php @@ -0,0 +1,64 @@ + + */ + public function getSearchCriterion(): array; + + /** + * Every metadata key `search()` may attach to a result, with its display label. + * + * @return array Key => UI display string + */ + public function getSearchResultMetadataKeys(): array; + + /** + * Search one account's data with the given query. + * + * @param non-empty-string $userId + * @return \Generator Each result contains the fields defined in {@see IAccountScopedSearchProvider::getSearchResultMetadataKeys()} in its metadata + */ + public function search( + string $userId, + ISearchQuery $query, + ): \Generator; + + /** May be evidence under legal hold: never call the returned file's mutating methods. */ + public function readContent(string $userId, AccountScopedSearchResult $entry): ?ISimpleFile; +} diff --git a/lib/unstable/Search/IAccountScopedSearchProviderRegistry.php b/lib/unstable/Search/IAccountScopedSearchProviderRegistry.php new file mode 100644 index 0000000000000..0bc8b98dbb348 --- /dev/null +++ b/lib/unstable/Search/IAccountScopedSearchProviderRegistry.php @@ -0,0 +1,39 @@ + 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/MetadataField.php b/lib/unstable/Search/MetadataField.php new file mode 100644 index 0000000000000..10321e3762f73 --- /dev/null +++ b/lib/unstable/Search/MetadataField.php @@ -0,0 +1,51 @@ +name; + } + + public function getStatus(): MetadataFieldStatus { + return $this->status; + } + + public function getValue(): mixed { + return $this->value; + } + + public function getReason(): ?string { + return $this->reason; + } +} diff --git a/lib/unstable/Search/MetadataFieldStatus.php b/lib/unstable/Search/MetadataFieldStatus.php new file mode 100644 index 0000000000000..7d4ddcb6c13ae --- /dev/null +++ b/lib/unstable/Search/MetadataFieldStatus.php @@ -0,0 +1,21 @@ +