mirror of
https://github.com/nextcloud/server.git
synced 2026-11-12 04:32:03 +01:00
642 lines
21 KiB
PHP
642 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace OCA\Files\Search;
|
|
|
|
use NCU\Search\AccountScopedSearchResult;
|
|
use NCU\Search\Exceptions\AccountUnavailableException;
|
|
use NCU\Search\Exceptions\SearchTruncatedException;
|
|
use NCU\Search\IAccountScopedSearchProvider;
|
|
use NCU\Search\SearchPropertyDefinition;
|
|
use NCU\Search\SearchPropertyType;
|
|
use OC\Files\Cache\Wrapper\CacheJail;
|
|
use OC\Files\Search\SearchBinaryOperator;
|
|
use OC\Files\Search\SearchComparison;
|
|
use OC\Files\Search\SearchOrder;
|
|
use OC\Files\Search\SearchQuery;
|
|
use OC\Files\SimpleFS\SimpleFile;
|
|
use OCP\Files\Cache\ICache;
|
|
use OCP\Files\File;
|
|
use OCP\Files\FileInfo;
|
|
use OCP\Files\Folder;
|
|
use OCP\Files\IRootFolder;
|
|
use OCP\Files\NotPermittedException;
|
|
use OCP\Files\Search\ISearchBinaryOperator;
|
|
use OCP\Files\Search\ISearchComparison;
|
|
use OCP\Files\Search\ISearchOperator;
|
|
use OCP\Files\Search\ISearchOrder;
|
|
use OCP\Files\SimpleFS\ISimpleFile;
|
|
use OCP\FullTextSearch\IFullTextSearchManager;
|
|
use OCP\IAppConfig;
|
|
use OCP\IL10N;
|
|
use OCP\Share\IManager as IShareManager;
|
|
use OCP\Share\IShare;
|
|
use OCP\SystemTag\ISystemTagManager;
|
|
use OCP\SystemTag\ISystemTagObjectMapper;
|
|
use OCP\User\Exceptions\UserNotFoundException;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
class FileAccountScopedSearchProvider implements IAccountScopedSearchProvider {
|
|
private const ID = 'files';
|
|
|
|
/** Share types that mean the content left the organisation. */
|
|
private const EXTERNAL_SHARE_TYPES = [
|
|
IShare::TYPE_LINK,
|
|
IShare::TYPE_EMAIL,
|
|
IShare::TYPE_REMOTE,
|
|
IShare::TYPE_REMOTE_GROUP,
|
|
];
|
|
|
|
/** Above this many content matches, the search is refused as truncated. */
|
|
private const CONTENT_MATCH_LIMIT = 5000;
|
|
|
|
/** Property names that differ from the filecache field they are searched on. */
|
|
private const FIELD_COLUMNS = [
|
|
'modified' => '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, $userFolder, $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<string, list<int>> $resolved accumulated so far
|
|
* @return array<string, list<int>>
|
|
*/
|
|
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<string, list<int>> $contentMatches
|
|
* @param list<int> $externallyShared
|
|
*/
|
|
private function resolveOperator(ISearchOperator $operator, Folder $userFolder, array $contentMatches, array $externallyShared): ISearchOperator {
|
|
if ($operator instanceof ISearchBinaryOperator) {
|
|
return new SearchBinaryOperator(
|
|
$operator->getType(),
|
|
array_map(fn (ISearchOperator $child): ISearchOperator
|
|
=> $this->resolveOperator($child, $userFolder, $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]);
|
|
}
|
|
|
|
if ($operator->getField() === 'path') {
|
|
return $this->pathToOperator($operator, $userFolder);
|
|
}
|
|
|
|
if (isset(self::FIELD_COLUMNS[$operator->getField()])) {
|
|
return new SearchComparison(
|
|
$operator->getType(),
|
|
self::FIELD_COLUMNS[$operator->getField()],
|
|
$operator->getValue(),
|
|
$operator->getExtra(),
|
|
);
|
|
}
|
|
|
|
return $operator;
|
|
}
|
|
|
|
/**
|
|
* A `path` comparison as filecache conditions. Paths are relative to the user folder, while the
|
|
* filecache stores them relative to the root of each mounted storage. Only the mounts are used
|
|
* to resolve them, so no filecache query is made.
|
|
*/
|
|
private function pathToOperator(ISearchComparison $comparison, Folder $userFolder): ISearchOperator {
|
|
$value = $comparison->getValue();
|
|
|
|
return match ($comparison->getType()) {
|
|
ISearchComparison::COMPARE_EQUAL => $this->pathEquals($userFolder, (string)$value),
|
|
ISearchComparison::COMPARE_IN => (array)$value === []
|
|
? $this->fileIdsToComparison([])
|
|
: new SearchBinaryOperator(
|
|
ISearchBinaryOperator::OPERATOR_OR,
|
|
array_map(fn (mixed $path): ISearchOperator => $this->pathEquals($userFolder, (string)$path), (array)$value),
|
|
),
|
|
ISearchComparison::COMPARE_LIKE,
|
|
ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => $this->folderContents($userFolder, $this->likeFolderPath((string)$value)),
|
|
default => throw new \InvalidArgumentException('Unsupported comparison for field path: ' . $comparison->getType()),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The cache of the storage a user folder path is mounted from, and the path inside that storage.
|
|
*
|
|
* @return array{ICache, string, string}|null cache, internal path and absolute path
|
|
*/
|
|
private function locate(Folder $userFolder, string $path): ?array {
|
|
try {
|
|
$fullPath = $userFolder->getFullPath($path);
|
|
} catch (NotPermittedException) {
|
|
return null;
|
|
}
|
|
|
|
$mount = $this->rootFolder->getMount($fullPath);
|
|
$cache = $mount->getStorage()?->getCache();
|
|
|
|
return $cache === null ? null : [$cache, $mount->getInternalPath($fullPath), $fullPath];
|
|
}
|
|
|
|
/**
|
|
* A path inside a storage, with the roots of any jails the cache is wrapped in prepended.
|
|
*/
|
|
private function unjailedPath(ICache $cache, string $internalPath): string {
|
|
return (new CacheJail($cache, $internalPath))->getGetUnjailedRoot();
|
|
}
|
|
|
|
private function pathEquals(Folder $userFolder, string $path): ISearchOperator {
|
|
$location = $this->locate($userFolder, $path);
|
|
if ($location === null) {
|
|
return $this->fileIdsToComparison([]);
|
|
}
|
|
[$cache, $internalPath] = $location;
|
|
|
|
return new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
|
|
new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'storage', $cache->getNumericStorageId()),
|
|
new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'path', $this->unjailedPath($cache, $internalPath)),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* The folder a `<folder>/%` LIKE pattern selects. Other patterns cannot be mapped onto the
|
|
* storages mounted in the user folder.
|
|
*/
|
|
private function likeFolderPath(string $pattern): string {
|
|
$literals = $this->likeLiterals($pattern);
|
|
if (count($literals) !== 2 || $literals[1] !== '' || !str_ends_with($pattern, '%') || !str_ends_with($literals[0], '/')) {
|
|
throw new \InvalidArgumentException('A path pattern must have the form "<folder>/%": ' . $pattern);
|
|
}
|
|
|
|
return substr($literals[0], 0, -1);
|
|
}
|
|
|
|
/**
|
|
* The literal parts of a LIKE pattern, split at its unescaped `%` and `_` wildcards, with the
|
|
* `\%`, `\_` and `\\` escapes resolved.
|
|
*
|
|
* @return non-empty-list<string>
|
|
*/
|
|
private function likeLiterals(string $pattern): array {
|
|
$literals = [];
|
|
$literal = '';
|
|
$length = strlen($pattern);
|
|
for ($i = 0; $i < $length; $i++) {
|
|
$char = $pattern[$i];
|
|
if ($char === '\\' && $i + 1 < $length) {
|
|
$literal .= $pattern[++$i];
|
|
} elseif ($char === '%' || $char === '_') {
|
|
$literals[] = $literal;
|
|
$literal = '';
|
|
} else {
|
|
$literal .= $char;
|
|
}
|
|
}
|
|
$literals[] = $literal;
|
|
|
|
return $literals;
|
|
}
|
|
|
|
/**
|
|
* Everything below a folder: its own storage from the folder down, and every storage mounted
|
|
* inside it.
|
|
*/
|
|
private function folderContents(Folder $userFolder, string $path): ISearchOperator {
|
|
$location = $this->locate($userFolder, $path);
|
|
if ($location === null) {
|
|
return $this->fileIdsToComparison([]);
|
|
}
|
|
[$cache, $internalPath, $fullPath] = $location;
|
|
|
|
$filters = [$internalPath === ''
|
|
? $cache->getQueryFilterForStorage()
|
|
: new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [
|
|
new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'storage', $cache->getNumericStorageId()),
|
|
new SearchComparison(
|
|
ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE,
|
|
'path',
|
|
SearchComparison::escapeLikeParameter($this->unjailedPath($cache, $internalPath)) . '/%',
|
|
),
|
|
])];
|
|
|
|
foreach ($this->rootFolder->getMountsIn($fullPath) as $mount) {
|
|
$storage = $mount->getStorage();
|
|
if ($storage !== null) {
|
|
$filters[] = $storage->getCache()->getQueryFilterForStorage();
|
|
}
|
|
}
|
|
|
|
return new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $filters);
|
|
}
|
|
|
|
/**
|
|
* The text a `content` comparison searches for. The index always matches a term anywhere in
|
|
* the content, so the wildcards of a LIKE pattern only separate words.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
return trim(implode(' ', $this->likeLiterals($value)));
|
|
}
|
|
|
|
/**
|
|
* Fileids the index says contain one term, for one account.
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
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<int> $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<int>
|
|
*/
|
|
private function externallySharedFileIds(string $userId): array {
|
|
$fileIds = [];
|
|
foreach (self::EXTERNAL_SHARE_TYPES as $shareType) {
|
|
foreach ($this->shareManager->getSharesBy($userId, $shareType, null, false, -1) as $share) {
|
|
$fileIds[$share->getNodeId()] = true;
|
|
}
|
|
}
|
|
|
|
return array_keys($fileIds);
|
|
}
|
|
|
|
/**
|
|
* @return array{shared: bool, externally: bool, shares: list<array{type: int, external: bool, recipient: ?string, shareId: string}>}
|
|
*/
|
|
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);
|
|
foreach ($this->shareManager->getSharesBy($owner, $shareType, $node, true, -1) 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(),
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'shared' => $entries !== [],
|
|
'externally' => $externally,
|
|
'shares' => $entries,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The user-visible tags on this file.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
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;
|
|
}
|
|
}
|