f7cloud_client/apps/groupfolders/lib/Trash/TrashBackend.php
root 8b6a0139db f7cloud_client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 22:59:26 +00:00

609 lines
21 KiB
PHP

<?php
declare (strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 F7cloud GmbH and F7cloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\GroupFolders\Trash;
use OC\Encryption\Exceptions\DecryptionFailedException;
use OC\Files\ObjectStore\ObjectStoreStorage;
use OC\Files\Storage\Wrapper\Encryption;
use OC\Files\Storage\Wrapper\Jail;
use OCA\Files_Trashbin\Expiration;
use OCA\Files_Trashbin\Storage;
use OCA\Files_Trashbin\Trash\ITrashBackend;
use OCA\Files_Trashbin\Trash\ITrashItem;
use OCA\GroupFolders\ACL\ACLManagerFactory;
use OCA\GroupFolders\Folder\FolderDefinitionWithPermissions;
use OCA\GroupFolders\Folder\FolderManager;
use OCA\GroupFolders\Folder\FolderWithMappingsAndCache;
use OCA\GroupFolders\Mount\GroupFolderStorage;
use OCA\GroupFolders\Mount\MountProvider;
use OCA\GroupFolders\Versions\VersionsBackend;
use OCP\Constants;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Mount\IMountManager;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\Storage\ISharedStorage;
use OCP\Files\Storage\IStorage;
use OCP\Files\Storage\IStorageFactory;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
class TrashBackend implements ITrashBackend {
private ?VersionsBackend $versionsBackend = null;
public function __construct(
private readonly FolderManager $folderManager,
private readonly TrashManager $trashManager,
private readonly ACLManagerFactory $aclManagerFactory,
private readonly IRootFolder $rootFolder,
private readonly LoggerInterface $logger,
private readonly IUserManager $userManager,
private readonly IUserSession $userSession,
private readonly MountProvider $mountProvider,
private readonly IMountManager $mountManager,
private readonly IStorageFactory $storageFactory,
) {
}
public function setVersionsBackend(VersionsBackend $versionsBackend): void {
$this->versionsBackend = $versionsBackend;
}
/**
* @return list<ITrashItem>
*/
public function listTrashRoot(IUser $user): array {
$folders = $this->folderManager->getFoldersForUser($user);
return $this->getTrashForFolders($user, $folders);
}
/**
* @return list<ITrashItem>
*/
public function listTrashFolder(ITrashItem $folder): array {
if (!$folder instanceof GroupTrashItem) {
return [];
}
$user = $folder->getUser();
$folderNode = $this->getNodeForTrashItem($user, $folder);
if (!$folderNode instanceof Folder) {
return [];
}
$content = $folderNode->getDirectoryListing();
$this->aclManagerFactory->getACLManager($user)->preloadRulesForFolder($folder->getGroupFolderStorageId(), $folder->getId());
return array_values(array_filter(array_map(function (Node $node) use ($folder, $user): ?GroupTrashItem {
$item = new GroupTrashItem(
$this,
$folder->getInternalOriginalLocation() . '/' . $node->getName(),
$folder->getDeletedTime(),
$folder->getTrashPath() . '/' . $node->getName(),
$node,
$user,
$folder->getGroupFolderMountPoint(),
$folder->getDeletedBy(),
$folder->folder,
);
if (!$this->userHasAccessToItem($item)) {
return null;
}
return $item;
}, $content)));
}
/**
* @throws NotPermittedException
*/
public function restoreItem(ITrashItem $item): void {
if (!($item instanceof GroupTrashItem)) {
throw new \LogicException('Trying to restore normal trash item in Team folder trash backend');
}
$user = $item->getUser();
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$folderId = $item->folder->id;
$node = $this->getNodeForTrashItem($user, $item);
if ($node === null) {
throw new NotFoundException();
}
if (!$this->userHasAccessToItem($item, Constants::PERMISSION_UPDATE)) {
throw new NotPermittedException();
}
$folderPermissions = $this->folderManager->getFolderPermissionsForUser($item->getUser(), $folderId);
if (($folderPermissions & Constants::PERMISSION_UPDATE) !== Constants::PERMISSION_UPDATE) {
throw new NotPermittedException();
}
$trashStorage = $node->getStorage();
/** @var Folder $targetFolder */
$targetFolder = $userFolder->get($item->getGroupFolderMountPoint());
$originalLocation = $item->getInternalOriginalLocation();
$parent = dirname($originalLocation);
if ($parent === '.') {
$parent = '';
}
if ($parent !== '' && !$targetFolder->nodeExists($parent)) {
$originalLocation = basename($originalLocation);
}
if ($targetFolder->nodeExists($originalLocation)) {
$info = pathinfo($originalLocation);
$i = 1;
$gen = function (array $info, int $i): string {
$target = $info['dirname'];
if ($target === '.') {
$target = '';
}
$target .= $info['filename'];
$target .= ' (' . $i . ')';
if (isset($info['extension'])) {
$target .= '.' . $info['extension'];
}
return $target;
};
do {
$originalLocation = $gen($info, $i);
$i++;
} while ($targetFolder->nodeExists($originalLocation));
}
$targetLocation = $targetFolder->getInternalPath() . '/' . $originalLocation;
$targetStorage = $targetFolder->getStorage();
$trashLocation = $node->getInternalPath();
try {
$targetStorage->moveFromStorage($trashStorage, $trashLocation, $targetLocation);
$targetStorage->getUpdater()->renameFromStorage($trashStorage, $trashLocation, $targetLocation);
} catch (DecryptionFailedException) {
// Before https://github.com/f7cloud/groupfolders/pull/3425 the key would be in the wrong place, leading to the decryption failure.
// for those we fall back to the old restore behavior
[$unwrappedTargetStorage, $unwrappedTargetLocation] = $this->unwrapJails($targetStorage, $targetLocation);
[$unwrappedTrashStorage, $unwrappedTrashLocation] = $this->unwrapJails($trashStorage, $trashLocation);
$unwrappedTargetStorage->moveFromStorage($unwrappedTrashStorage, $unwrappedTrashLocation, $unwrappedTargetLocation);
$unwrappedTargetStorage->getUpdater()->renameFromStorage($unwrappedTrashStorage, $unwrappedTrashLocation, $unwrappedTargetLocation);
}
$this->trashManager->removeItem($folderId, $item->getName(), $item->getDeletedTime());
\OCP\Util::emitHook(
'\OCA\Files_Trashbin\Trashbin',
'post_restore',
[
'filePath' => '/' . $item->getGroupFolderMountPoint() . '/' . $originalLocation,
'trashPath' => $item->getPath(),
],
);
}
private function unwrapJails(IStorage $storage, string $internalPath): array {
$unJailedInternalPath = $internalPath;
$unJailedStorage = $storage;
while ($unJailedStorage->instanceOfStorage(Jail::class)) {
$unJailedStorage = $unJailedStorage->getWrapperStorage();
if ($unJailedStorage instanceof Jail) {
$unJailedInternalPath = $unJailedStorage->getUnjailedPath($unJailedInternalPath);
}
}
return [$unJailedStorage, $unJailedInternalPath];
}
/**
* @throws \LogicException
* @throws \Exception
*/
public function removeItem(ITrashItem $item): void {
if (!($item instanceof GroupTrashItem)) {
throw new \LogicException('Trying to remove normal trash item in Team folder trash backend');
}
$user = $item->getUser();
$folderId = $item->folder->id;
$node = $this->getNodeForTrashItem($user, $item);
if ($node === null) {
throw new NotFoundException();
}
if (!$this->userHasAccessToItem($item, Constants::PERMISSION_DELETE)) {
throw new NotPermittedException();
}
$folderPermissions = $this->folderManager->getFolderPermissionsForUser($item->getUser(), $folderId);
if (($folderPermissions & Constants::PERMISSION_DELETE) !== Constants::PERMISSION_DELETE) {
throw new NotPermittedException();
}
if ($node->getStorage()->unlink($node->getInternalPath()) === false) {
throw new \Exception('Failed to remove item from trashbin');
}
$node->getStorage()->getCache()->remove($node->getInternalPath());
if ($item->isRootItem()) {
$this->trashManager->removeItem($folderId, $item->getName(), $item->getDeletedTime());
}
}
public function moveToTrash(IStorage $storage, string $internalPath): bool {
if ($storage->instanceOfStorage(GroupFolderStorage::class) && $storage->isDeletable($internalPath)) {
/** @var GroupFolderStorage $storage */
$name = basename($internalPath);
$fileEntry = $storage->getCache()->get($internalPath);
$folder = $storage->getFolder();
$folderId = $storage->getFolderId();
$trashFolder = $this->setupTrashFolder($folder, $storage->getUser());
$trashStorage = $trashFolder->getStorage();
$time = time();
$trashName = $name . '.d' . $time;
$targetInternalPath = $trashFolder->getInternalPath() . '/' . $trashName;
// until the fix from https://github.com/f7cloud/server/pull/49262 is in all versions we support we need to manually disable the optimization
if ($storage->instanceOfStorage(Encryption::class)) {
$result = $this->moveFromEncryptedStorage($storage, $trashStorage, $internalPath, $targetInternalPath);
} else {
$result = $trashStorage->moveFromStorage($storage, $internalPath, $targetInternalPath);
}
if ($result) {
$originalLocation = $internalPath;
if ($storage->instanceOfStorage(ISharedStorage::class)) {
$originalLocation = $storage->getWrapperStorage()->getUnjailedPath($originalLocation);
}
$deletedBy = $this->userSession->getUser();
$this->trashManager->addTrashItem($folderId, $name, $time, $originalLocation, $fileEntry->getId(), $deletedBy?->getUID() ?? '');
// some storage backends (object/encryption) can either already move the cache item or cause the target to be scanned
// so we only conditionally do the cache move here
if (!$trashStorage->getCache()->inCache($targetInternalPath)) {
// doesn't exist in target yet, do the move
$trashStorage->getCache()->moveFromCache($storage->getCache(), $internalPath, $targetInternalPath);
} elseif ($storage->getCache()->inCache($internalPath)) {
// exists in both source and target, cleanup source
$storage->getCache()->remove($internalPath);
}
} else {
throw new \Exception('Failed to move Team folder item to trash');
}
return true;
} else {
return false;
}
}
/**
* move from storage when we can't just move within the storage
*
* This is copied from the fallback implementation from Common::moveFromStorage
*/
private function moveFromEncryptedStorage(IStorage $sourceStorage, IStorage $targetStorage, string $sourceInternalPath, string $targetInternalPath): bool {
if (!$sourceStorage->isDeletable($sourceInternalPath)) {
return false;
}
// the trash should be the top wrapper, remove it to prevent recursive attempts to move to trash
if ($sourceStorage instanceof Storage) {
$sourceStorage = $sourceStorage->getWrapperStorage();
}
$result = $targetStorage->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath, true);
if ($result) {
if ($sourceStorage->instanceOfStorage(ObjectStoreStorage::class)) {
/** @var ObjectStoreStorage $sourceStorage */
$sourceStorage->setPreserveCacheOnDelete(true);
}
try {
if ($sourceStorage->is_dir($sourceInternalPath)) {
$result = $sourceStorage->rmdir($sourceInternalPath);
} else {
$result = $sourceStorage->unlink($sourceInternalPath);
}
} finally {
if ($sourceStorage->instanceOfStorage(ObjectStoreStorage::class)) {
/** @var ObjectStoreStorage $sourceStorage */
$sourceStorage->setPreserveCacheOnDelete(false);
}
}
}
return $result;
}
private function userHasAccessToFolder(IUser $user, int $folderId): bool {
return $this->folderManager->getFoldersForUser($user, $folderId) !== [];
}
private function userHasAccessToItem(
GroupTrashItem $item,
int $permission = Constants::PERMISSION_READ,
string $pathInsideItem = '',
): bool {
try {
$aclManager = $this->aclManagerFactory->getACLManager($item->getUser());
$trashPath = $this->getUnJailedPath($item->getTrashNode()) . $pathInsideItem;
$activePermissions = $aclManager->getACLPermissionsForPath($item->getGroupTrashFolderStorageId(), $trashPath);
$originalPath = $item->folder->rootCacheEntry->getPath() . '/' . $item->getInternalOriginalLocation() . $pathInsideItem;
$originalLocationPermissions = $aclManager->getACLPermissionsForPath($item->getGroupFolderStorageId(), $originalPath);
} catch (\Exception $e) {
$this->logger->warning("Failed to get permissions for {$item->getPath()}", ['exception' => $e]);
return false;
}
return (bool)($activePermissions & $permission & $originalLocationPermissions);
}
private function userHasAccessToPath(
int $storageId,
IUser $user,
string $path,
int $permission = Constants::PERMISSION_READ,
): bool {
try {
$activePermissions = $this->aclManagerFactory->getACLManager($user)
->getACLPermissionsForPath($storageId, $path);
} catch (\Exception $e) {
$this->logger->warning("Failed to get permissions for {$path}", ['exception' => $e]);
return false;
}
return (bool)($activePermissions & $permission);
}
private function getNodeForTrashItem(IUser $user, ITrashItem $trashItem): ?Node {
if (!($trashItem instanceof GroupTrashItem)) {
throw new \LogicException('Trying to remove normal trash item in Team folder trash backend');
}
$folderId = $trashItem->folder->id;
$path = $trashItem->getFullInternalPath();
$folders = $this->folderManager->getFoldersForUser($user, $folderId);
foreach ($folders as $groupFolder) {
if ($groupFolder->id === $folderId) {
$trashRoot = $this->setupTrashFolder($groupFolder, $user);
try {
$node = $trashRoot->get($path);
if (!$this->userHasAccessToItem($trashItem)) {
return null;
}
return $node;
} catch (NotFoundException) {
return null;
}
}
}
return null;
}
private function setupTrashFolder(FolderDefinitionWithPermissions $folder, ?IUser $user = null): Folder {
$folderId = $folder->id;
$uid = $user ? $user->getUID() : 'dummy';
$mountPoint = '/' . $uid . '/files_trashbin/groupfolders/' . $folderId;
$mount = $this->mountManager->find($mountPoint);
if ($mount->getMountPoint() !== $mountPoint) {
$trashMount = $this->mountProvider->getTrashMount(
$folder,
$mountPoint,
$this->storageFactory,
$user,
);
$this->mountManager->addMount($trashMount);
}
return $this->rootFolder->get('/' . $uid . '/files_trashbin/groupfolders/' . $folderId);
}
private function getUnJailedPath(Node $node): string {
$storage = $node->getStorage();
$path = $node->getInternalPath();
while ($storage->instanceOfStorage(Jail::class)) {
/** @var Jail $storage */
$path = $storage->getUnjailedPath($path);
$storage = $storage->getUnjailedStorage();
}
return $path;
}
/**
* @param list<FolderDefinitionWithPermissions> $folders
* @return list<ITrashItem>
*/
private function getTrashForFolders(IUser $user, array $folders): array {
$folderIds = array_map(fn (FolderDefinitionWithPermissions $folder): int => $folder->id, $folders);
$rows = $this->trashManager->listTrashForFolders($folderIds);
$indexedRows = [];
foreach ($rows as $row) {
$key = $row['folder_id'] . '/' . $row['name'] . '/' . $row['deleted_time'];
$indexedRows[$key] = $row;
}
$items = [];
foreach ($folders as $folder) {
// note that we explicitly don't pass the user here, was we need to get all trash items,
// not only the trash items we have access to (so we can get their original paths)
// we apply acl filtering later to get the correct permissions again
$trashFolder = $this->setupTrashFolder($folder);
$content = $trashFolder->getDirectoryListing();
$userCanManageAcl = $this->folderManager->canManageACL($folder->id, $user);
$this->aclManagerFactory->getACLManager($user)->preloadRulesForFolder($folder->storageId, $trashFolder->getId());
$itemsForFolder = array_map(function (Node $item) use ($user, $folder, $indexedRows) {
$pathParts = pathinfo($item->getName());
$timestamp = (int)substr($pathParts['extension'], 1);
$name = $pathParts['filename'];
$key = $folder->id . '/' . $name . '/' . $timestamp;
$originalLocation = isset($indexedRows[$key]) ? $indexedRows[$key]['original_location'] : '';
$deletedBy = isset($indexedRows[$key]) ? $indexedRows[$key]['deleted_by'] : '';
return new GroupTrashItem(
$this,
$originalLocation,
$timestamp,
'/' . $folder->id . '/' . $item->getName(),
$item,
$user,
$folder->mountPoint,
$this->userManager->get($deletedBy),
$folder,
);
}, $content);
$originalLocations = array_map(fn (GroupTrashItem $item): string => $item->getOriginalLocation(), $itemsForFolder);
$itemsByOriginalLocation = array_combine($originalLocations, $itemsForFolder);
// perform per-item ACL checks if the user doesn't have manage permissions
if ($folder->acl && !$userCanManageAcl) {
$itemsForFolder = array_filter($itemsForFolder, function (GroupTrashItem $item) use ($itemsByOriginalLocation) {
// if we for any reason lost track of the original location, hide the item for non-managers as a fail-safe
if ($item->getInternalOriginalLocation() === '') {
return false;
}
if (!$this->userHasAccessToItem($item)) {
return false;
}
// if a parent of the original location has also been deleted, we also need to check it based on the now-deleted parent path
foreach ($this->getDeletedParentOriginalPaths($item->getOriginalLocation(), $itemsByOriginalLocation) as $parentItem) {
$pathInsideParentItem = dirname(substr($item->getInternalOriginalLocation(), strlen($parentItem->getInternalOriginalLocation())));
if (!$this->userHasAccessToItem($parentItem, Constants::PERMISSION_READ, $pathInsideParentItem)) {
return false;
}
}
return true;
});
}
$items = array_merge($items, $itemsForFolder);
}
return $items;
}
/**
* @param array<string, GroupTrashItem> $trashItemsByOriginalPath
* @return list<GroupTrashItem>
*/
private function getDeletedParentOriginalPaths(string $path, array $trashItemsByOriginalPath): array {
$parentItems = [];
while ($path !== '') {
$path = dirname($path);
if ($path === '.' || $path === '/') {
break;
} elseif (isset($trashItemsByOriginalPath[$path])) {
$parentItems[] = $trashItemsByOriginalPath[$path];
}
}
return $parentItems;
}
public function getTrashNodeById(IUser $user, int $fileId): ?Node {
try {
$folders = $this->folderManager->getFoldersForUser($user);
foreach ($folders as $folder) {
$trashFolder = $this->setupTrashFolder($folder, $user);
if ($path = $trashFolder->getStorage()->getCache()->getPathById($fileId)) {
return $trashFolder->get($path);
}
}
return null;
} catch (NotFoundException) {
return null;
}
}
public function cleanTrashFolder(FolderDefinitionWithPermissions $folder): void {
$trashFolder = $this->setupTrashFolder($folder);
foreach ($trashFolder->getDirectoryListing() as $node) {
$node->delete();
}
$this->trashManager->emptyTrashbin($folder->id);
}
public function expire(Expiration $expiration): array {
$size = 0;
$count = 0;
$folders = $this->folderManager->getAllFoldersWithSize();
$folders = array_map(function (FolderWithMappingsAndCache $folder): FolderDefinitionWithPermissions {
return FolderDefinitionWithPermissions::fromFolder($folder, $folder->rootCacheEntry, Constants::PERMISSION_ALL);
}, $folders);
foreach ($folders as $folder) {
$folderId = $folder->id;
$trashItems = $this->trashManager->listTrashForFolders([$folderId]);
// calculate size of trash items
$sizeInTrash = 0;
$trashFolder = $this->setupTrashFolder($folder);
$nodes = []; // cache
foreach ($trashItems as $groupTrashItem) {
$nodeName = $groupTrashItem['name'] . '.d' . $groupTrashItem['deleted_time'];
try {
$nodes[$nodeName] = $node = $trashFolder->get($nodeName);
} catch (NotFoundException) {
$this->trashManager->removeItem($folderId, $groupTrashItem['name'], $groupTrashItem['deleted_time']);
continue;
}
$sizeInTrash += $node->getSize();
}
$size = $folder->rootCacheEntry->getSize();
foreach ($trashItems as $groupTrashItem) {
$nodeName = $groupTrashItem['name'] . '.d' . $groupTrashItem['deleted_time'];
if (!isset($nodes[$nodeName])) {
continue;
}
$node = $nodes[$nodeName];
if ($expiration->isExpired($groupTrashItem['deleted_time'], $folder->quota > 0 && $folder->quota < ($size + $sizeInTrash))) {
$this->logger->debug('expiring ' . $node->getPath());
if ($node->getStorage()->unlink($node->getInternalPath()) === false) {
$this->logger->error('Failed to remove item from trashbin: ' . $node->getPath());
continue;
}
// only count up after checking if removal is possible
$count += 1;
$size += $node->getSize();
$size -= $node->getSize();
$node->getStorage()->getCache()->remove($node->getInternalPath());
$this->trashManager->removeItem($folderId, $groupTrashItem['name'], $groupTrashItem['deleted_time']);
if (!is_null($groupTrashItem['file_id']) && !is_null($this->versionsBackend)) {
$this->versionsBackend->deleteAllVersionsForFile($folder, $groupTrashItem['file_id']);
}
} else {
$this->logger->debug($node->getPath() . " isn't set to be expired yet, stopping expiry");
break;
}
}
}
return [$count, $size];
}
}