nextcloud/apps/files_sharing/lib/SharedStorage.php
Côme Chilliet 9c92cc1617
chore: Suppress last known impure static properties
We will keep these legacy ones for now. We can search for the
 ImpureStaticProperty suppression and add special treatement for them in
 the frankenphp PR if needed.

Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
2026-06-02 09:46:20 +02:00

603 lines
17 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Files_Sharing;
use OC\Files\Cache\CacheDependencies;
use OC\Files\Cache\CacheEntry;
use OC\Files\Cache\FailedCache;
use OC\Files\Cache\NullWatcher;
use OC\Files\ObjectStore\HomeObjectStoreStorage;
use OC\Files\Storage\Common;
use OC\Files\Storage\FailedStorage;
use OC\Files\Storage\Home;
use OC\Files\Storage\Storage;
use OC\Files\Storage\Wrapper\Jail;
use OC\Files\Storage\Wrapper\PermissionsMask;
use OC\Files\Storage\Wrapper\Wrapper;
use OC\Files\View;
use OC\Share\Share;
use OC\User\NoUserException;
use OCA\Files_Sharing\ISharedStorage as LegacyISharedStorage;
use OCP\Constants;
use OCP\Files\Cache\ICache;
use OCP\Files\Cache\ICacheEntry;
use OCP\Files\Cache\IScanner;
use OCP\Files\Cache\IWatcher;
use OCP\Files\Folder;
use OCP\Files\IHomeStorage;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\Storage\IDisableEncryptionStorage;
use OCP\Files\Storage\ILockingStorage;
use OCP\Files\Storage\ISharedStorage;
use OCP\Files\Storage\IStorage;
use OCP\IAppConfig;
use OCP\IUserSession;
use OCP\Lock\ILockingProvider;
use OCP\Server;
use OCP\Share\IManager as IShareManager;
use OCP\Share\IShare;
use OCP\Util;
use Override;
use Psr\Log\LoggerInterface;
/**
* Convert target path to source path and pass the function call to the correct storage provider
*/
class SharedStorage extends Jail implements LegacyISharedStorage, ISharedStorage, IDisableEncryptionStorage {
/** @var IShare */
private $superShare;
/** @var IShare[] */
private $groupedShares;
/**
* @var View
*/
private $ownerView;
private $initialized = false;
/**
* @var ICacheEntry
*/
private $sourceRootInfo;
/** @var string */
private $user;
private LoggerInterface $logger;
/** @var IStorage */
private $nonMaskedStorage;
private array $mountOptions = [];
/** @var boolean */
private $sharingDisabledForUser;
/** @var ?Folder $ownerUserFolder */
private $ownerUserFolder = null;
private string $sourcePath = '';
private IAppConfig $appConfig;
private IShareManager $shareManager;
/**
* @psalm-suppress ImpureStaticProperty
*/
private static int $initDepth = 0;
public function __construct(array $parameters) {
$this->ownerView = $parameters['ownerView'];
$this->logger = Server::get(LoggerInterface::class);
$this->appConfig = Server::get(IAppConfig::class);
$this->shareManager = Server::get(IShareManager::class);
$this->superShare = $parameters['superShare'];
$this->groupedShares = $parameters['groupedShares'];
$this->user = $parameters['user'];
if (isset($parameters['sharingDisabledForUser'])) {
$this->sharingDisabledForUser = $parameters['sharingDisabledForUser'];
} else {
$this->sharingDisabledForUser = false;
}
parent::__construct([
'storage' => null,
'root' => null,
]);
}
/**
* @return ICacheEntry
*/
private function getSourceRootInfo() {
if (is_null($this->sourceRootInfo)) {
if (is_null($this->superShare->getNodeCacheEntry())) {
$this->init();
$this->sourceRootInfo = $this->nonMaskedStorage->getCache()->get($this->rootPath);
} else {
$this->sourceRootInfo = $this->superShare->getNodeCacheEntry();
}
}
return $this->sourceRootInfo;
}
/**
* @psalm-assert Storage $this->storage
*/
private function init() {
if ($this->initialized) {
if (!$this->storage) {
// marked as initialized but no storage set
// this is probably because some code path has caused recursion during the share setup
// we setup a "failed storage" so `getWrapperStorage` doesn't return null.
// If the share setup completes after this the "failed storage" will be overwritten by the correct one
$ex = new \Exception('Possible share setup recursion detected for share ' . $this->superShare->getId());
$this->logger->warning($ex->getMessage(), ['exception' => $ex, 'app' => 'files_sharing']);
$this->storage = new FailedStorage(['exception' => $ex]);
$this->cache = new FailedCache();
$this->rootPath = '';
}
return;
}
$this->initialized = true;
self::$initDepth++;
try {
if (self::$initDepth > 10) {
throw new \Exception('Maximum share depth reached');
}
/** @var IRootFolder $rootFolder */
$rootFolder = Server::get(IRootFolder::class);
$this->ownerUserFolder = $rootFolder->getUserFolder($this->superShare->getShareOwner());
$sourceId = $this->superShare->getNodeId();
$ownerNodes = $this->ownerUserFolder->getById($sourceId);
if (count($ownerNodes) === 0) {
$this->storage = new FailedStorage(['exception' => new NotFoundException("File by id $sourceId not found")]);
$this->cache = new FailedCache();
$this->rootPath = '';
} else {
foreach ($ownerNodes as $ownerNode) {
$nonMaskedStorage = $ownerNode->getStorage();
// check if potential source node would lead to a recursive share setup
if ($nonMaskedStorage instanceof Wrapper && $nonMaskedStorage->isWrapperOf($this)) {
continue;
}
$this->nonMaskedStorage = $nonMaskedStorage;
$this->sourcePath = $ownerNode->getPath();
$this->rootPath = $ownerNode->getInternalPath();
$this->cache = null;
break;
}
if (!$this->nonMaskedStorage) {
// all potential source nodes would have been recursive
throw new \Exception('recursive share detected');
}
$this->storage = new PermissionsMask([
'storage' => $this->nonMaskedStorage,
'mask' => $this->superShare->getPermissions(),
]);
}
} catch (NotFoundException $e) {
// original file not accessible or deleted, set FailedStorage
$this->storage = new FailedStorage(['exception' => $e]);
$this->cache = new FailedCache();
$this->rootPath = '';
} catch (NoUserException $e) {
// sharer user deleted, set FailedStorage
$this->storage = new FailedStorage(['exception' => $e]);
$this->cache = new FailedCache();
$this->rootPath = '';
} catch (\Exception $e) {
$this->storage = new FailedStorage(['exception' => $e]);
$this->cache = new FailedCache();
$this->rootPath = '';
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
if (!$this->nonMaskedStorage) {
$this->nonMaskedStorage = $this->storage;
}
self::$initDepth--;
}
#[\Override]
public function instanceOfStorage(string $class): bool {
if ($class === '\OC\Files\Storage\Common' || $class == Common::class) {
return true;
}
if (in_array($class, [
'\OC\Files\Storage\Home',
'\OC\Files\ObjectStore\HomeObjectStoreStorage',
'\OCP\Files\IHomeStorage',
Home::class,
HomeObjectStoreStorage::class,
IHomeStorage::class
])) {
return false;
}
return parent::instanceOfStorage($class);
}
/**
* @return string
*/
public function getShareId() {
return $this->superShare->getId();
}
private function isValid(): bool {
return $this->getSourceRootInfo() && ($this->getSourceRootInfo()->getPermissions() & Constants::PERMISSION_SHARE) === Constants::PERMISSION_SHARE;
}
#[\Override]
public function getId(): string {
return 'shared::' . $this->getMountPoint();
}
#[\Override]
public function getPermissions(string $path = ''): int {
if (!$this->isValid()) {
return 0;
}
$permissions = parent::getPermissions($path) & $this->superShare->getPermissions();
// part files and the mount point always have delete permissions
if ($path === '' || pathinfo($path, PATHINFO_EXTENSION) === 'part') {
$permissions |= Constants::PERMISSION_DELETE;
}
if ($this->sharingDisabledForUser) {
$permissions &= ~Constants::PERMISSION_SHARE;
}
return $permissions;
}
#[\Override]
public function isCreatable(string $path): bool {
return (bool)($this->getPermissions($path) & Constants::PERMISSION_CREATE);
}
#[\Override]
public function isReadable(string $path): bool {
if (!$this->isValid()) {
return false;
}
if (!$this->file_exists($path)) {
return false;
}
/** @var IStorage $storage */
/** @var string $internalPath */
[$storage, $internalPath] = $this->resolvePath($path);
return $storage->isReadable($internalPath);
}
#[\Override]
public function isUpdatable(string $path): bool {
return (bool)($this->getPermissions($path) & Constants::PERMISSION_UPDATE);
}
#[\Override]
public function isDeletable(string $path): bool {
return (bool)($this->getPermissions($path) & Constants::PERMISSION_DELETE);
}
#[\Override]
public function isSharable(string $path): bool {
if ($this->shareManager->sharingDisabledForUser(Server::get(IUserSession::class)->getUser()?->getUID())
|| !$this->appConfig->getValueBool('core', 'shareapi_allow_resharing', true)) {
return false;
}
return (bool)($this->getPermissions($path) & Constants::PERMISSION_SHARE);
}
#[\Override]
public function fopen(string $path, string $mode) {
$source = $this->getUnjailedPath($path);
switch ($mode) {
case 'r+':
case 'rb+':
case 'w+':
case 'wb+':
case 'x+':
case 'xb+':
case 'a+':
case 'ab+':
case 'w':
case 'wb':
case 'x':
case 'xb':
case 'a':
case 'ab':
$creatable = $this->isCreatable(dirname($path));
$updatable = $this->isUpdatable($path);
// if neither permissions given, no need to continue
if (!$creatable && !$updatable) {
if (pathinfo($path, PATHINFO_EXTENSION) === 'part') {
$updatable = $this->isUpdatable(dirname($path));
}
if (!$updatable) {
return false;
}
}
$exists = $this->file_exists($path);
// if a file exists, updatable permissions are required
if ($exists && !$updatable) {
return false;
}
// part file is allowed if !$creatable but the final file is $updatable
if (pathinfo($path, PATHINFO_EXTENSION) !== 'part') {
if (!$exists && !$creatable) {
return false;
}
}
}
$info = [
'target' => $this->getMountPoint() . '/' . $path,
'source' => $source,
'mode' => $mode,
];
Util::emitHook('\OC\Files\Storage\Shared', 'fopen', $info);
return $this->nonMaskedStorage->fopen($this->getUnjailedPath($path), $mode);
}
#[\Override]
public function rename(string $source, string $target): bool {
$this->init();
$isPartFile = pathinfo($source, PATHINFO_EXTENSION) === 'part';
$targetExists = $this->file_exists($target);
$sameFolder = dirname($source) === dirname($target);
if ($targetExists || ($sameFolder && !$isPartFile)) {
if (!$this->isUpdatable('')) {
return false;
}
} else {
if (!$this->isCreatable('')) {
return false;
}
}
return $this->nonMaskedStorage->rename($this->getUnjailedPath($source), $this->getUnjailedPath($target));
}
/**
* return mount point of share, relative to data/user/files
*
* @return string
*/
public function getMountPoint(): string {
return $this->superShare->getTarget();
}
public function setMountPoint(string $path): void {
$this->superShare->setTarget($path);
foreach ($this->groupedShares as $share) {
$share->setTarget($path);
}
}
/**
* get the user who shared the file
*
* @return string
*/
public function getSharedFrom(): string {
return $this->superShare->getShareOwner();
}
#[\Override]
public function getShare(): IShare {
return $this->superShare;
}
/**
* return share type, can be "file" or "folder"
*
* @return string
*/
public function getItemType(): string {
return $this->superShare->getNodeType();
}
#[\Override]
public function getCache(string $path = '', ?IStorage $storage = null): ICache {
if ($this->cache) {
return $this->cache;
}
if (!$storage) {
$storage = $this;
}
$sourceRoot = $this->getSourceRootInfo();
if ($this->storage instanceof FailedStorage) {
return new FailedCache();
}
$this->cache = new Cache(
$storage,
$sourceRoot,
Server::get(CacheDependencies::class),
$this->getShare()
);
return $this->cache;
}
#[\Override]
public function getScanner(string $path = '', ?IStorage $storage = null): IScanner {
if (!$storage) {
$storage = $this;
}
return new Scanner($storage);
}
#[\Override]
public function getOwner(string $path): string|false {
return $this->superShare->getShareOwner();
}
#[\Override]
public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher {
if ($this->watcher) {
return $this->watcher;
}
// Get node information
$node = $this->getShare()->getNodeCacheEntry();
if ($node instanceof CacheEntry) {
$storageId = $node->getData()['storage_string_id'] ?? null;
// for shares from the home storage we can rely on the home storage to keep itself up to date
// for other storages we need use the proper watcher
if ($storageId !== null && !(str_starts_with($storageId, 'home::') || str_starts_with($storageId, 'object::user'))) {
$cache = $this->getCache();
$this->watcher = parent::getWatcher($path, $storage);
if ($cache instanceof Cache) {
$this->watcher->onUpdate($cache->markRootChanged(...));
}
return $this->watcher;
}
}
// cache updating is handled by the share source
$this->watcher = new NullWatcher();
return $this->watcher;
}
/**
* unshare complete storage, also the grouped shares
*
* @return bool
*/
public function unshareStorage(): bool {
foreach ($this->groupedShares as $share) {
Server::get(IShareManager::class)->deleteFromSelf($share, $this->user);
}
return true;
}
#[\Override]
public function acquireLock(string $path, int $type, ILockingProvider $provider): void {
/** @var ILockingStorage $targetStorage */
[$targetStorage, $targetInternalPath] = $this->resolvePath($path);
$targetStorage->acquireLock($targetInternalPath, $type, $provider);
// lock the parent folders of the owner when locking the share as recipient
if ($path === '') {
$sourcePath = $this->ownerUserFolder->getRelativePath($this->sourcePath);
$this->ownerView->lockFile(dirname($sourcePath), ILockingProvider::LOCK_SHARED, true);
}
}
#[\Override]
public function releaseLock(string $path, int $type, ILockingProvider $provider): void {
/** @var ILockingStorage $targetStorage */
[$targetStorage, $targetInternalPath] = $this->resolvePath($path);
$targetStorage->releaseLock($targetInternalPath, $type, $provider);
// unlock the parent folders of the owner when unlocking the share as recipient
if ($path === '') {
$sourcePath = $this->ownerUserFolder->getRelativePath($this->sourcePath);
$this->ownerView->unlockFile(dirname($sourcePath), ILockingProvider::LOCK_SHARED, true);
}
}
#[\Override]
public function changeLock(string $path, int $type, ILockingProvider $provider): void {
/** @var ILockingStorage $targetStorage */
[$targetStorage, $targetInternalPath] = $this->resolvePath($path);
$targetStorage->changeLock($targetInternalPath, $type, $provider);
}
#[\Override]
public function getAvailability(): array {
// shares do not participate in availability logic
return [
'available' => true,
'last_checked' => 0,
];
}
#[\Override]
public function setAvailability(bool $isAvailable): void {
// shares do not participate in availability logic
}
public function getSourceStorage() {
$this->init();
return $this->nonMaskedStorage;
}
#[\Override]
public function getWrapperStorage(): Storage {
$this->init();
/**
* @psalm-suppress DocblockTypeContradiction
*/
if (!$this->storage) {
$message = 'no storage set after init for share ' . $this->getShareId();
$this->logger->error($message);
$this->storage = new FailedStorage(['exception' => new \Exception($message)]);
}
return $this->storage;
}
#[\Override]
public function file_get_contents(string $path): string|false {
$info = [
'target' => $this->getMountPoint() . '/' . $path,
'source' => $this->getUnjailedPath($path),
];
Util::emitHook('\OC\Files\Storage\Shared', 'file_get_contents', $info);
return parent::file_get_contents($path);
}
#[\Override]
public function file_put_contents(string $path, mixed $data): int|float|false {
$info = [
'target' => $this->getMountPoint() . '/' . $path,
'source' => $this->getUnjailedPath($path),
];
Util::emitHook('\OC\Files\Storage\Shared', 'file_put_contents', $info);
return parent::file_put_contents($path, $data);
}
public function setMountOptions(array $options): void {
/* Note: This value is never read */
$this->mountOptions = $options;
}
#[\Override]
public function getUnjailedPath(string $path): string {
$this->init();
return parent::getUnjailedPath($path);
}
#[Override]
public function getDirectDownload(string $path): array|false {
// disable direct download for shares
return false;
}
#[Override]
public function getDirectDownloadById(string $fileId): array|false {
// disable direct download for shares
return false;
}
}