Skip to content

Commit 355d663

Browse files
refactor(trashbin): restyle DAV handlers, enhance internal docs, refactor for clarity & robustness
Signed-off-by: Josh <[email protected]>
1 parent d79cf95 commit 355d663

File tree

1 file changed

+119
-99
lines changed

1 file changed

+119
-99
lines changed

apps/files_trashbin/lib/Sabre/TrashbinPlugin.php

Lines changed: 119 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
declare(strict_types=1);
44

55
/**
6-
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-FileCopyrightText: 2018-2025 Nextcloud GmbH and Nextcloud contributors
77
* SPDX-License-Identifier: AGPL-3.0-or-later
88
*/
99
namespace OCA\Files_Trashbin\Sabre;
@@ -22,6 +22,12 @@
2222
use Sabre\HTTP\ResponseInterface;
2323
use Sabre\Uri;
2424

25+
/**
26+
* SabreDAV server plugin for managing Nextcloud's trashbin features.
27+
*
28+
* Handles events and properties related to deleted files, such as restoration, quota checks, and
29+
* custom responses for trashbin resources over WebDAV.
30+
*/
2531
class TrashbinPlugin extends ServerPlugin {
2632
public const TRASHBIN_FILENAME = '{http://nextcloud.org/ns}trashbin-filename';
2733
public const TRASHBIN_ORIGINAL_LOCATION = '{http://nextcloud.org/ns}trashbin-original-location';
@@ -30,17 +36,17 @@ class TrashbinPlugin extends ServerPlugin {
3036
public const TRASHBIN_DELETED_BY_ID = '{http://nextcloud.org/ns}trashbin-deleted-by-id';
3137
public const TRASHBIN_DELETED_BY_DISPLAY_NAME = '{http://nextcloud.org/ns}trashbin-deleted-by-display-name';
3238
public const TRASHBIN_BACKEND = '{http://nextcloud.org/ns}trashbin-backend';
39+
public const TRASHBIN_RESTORE_SPACE_SAFETY_MARGIN = 65536; // 64 KiB
3340

34-
/** @var Server */
35-
private $server;
41+
private Server $server;
3642

3743
public function __construct(
38-
private IPreview $previewManager,
39-
private View $view,
44+
private readonly IPreview $previewManager,
45+
private readonly View $view,
4046
) {
4147
}
4248

43-
public function initialize(Server $server) {
49+
public function initialize(Server $server): void {
4450
$this->server = $server;
4551

4652
$this->server->on('propFind', [$this, 'propFind']);
@@ -49,131 +55,145 @@ public function initialize(Server $server) {
4955
}
5056

5157

52-
public function propFind(PropFind $propFind, INode $node) {
58+
public function propFind(PropFind $propFind, INode $node): void {
59+
// Only act on trashbin nodes
5360
if (!($node instanceof ITrash)) {
5461
return;
5562
}
5663

57-
$propFind->handle(self::TRASHBIN_FILENAME, function () use ($node) {
58-
return $node->getFilename();
59-
});
60-
61-
$propFind->handle(self::TRASHBIN_ORIGINAL_LOCATION, function () use ($node) {
62-
return $node->getOriginalLocation();
63-
});
64-
65-
$propFind->handle(self::TRASHBIN_TITLE, function () use ($node) {
66-
return $node->getTitle();
67-
});
68-
69-
$propFind->handle(self::TRASHBIN_DELETION_TIME, function () use ($node) {
70-
return $node->getDeletionTime();
71-
});
72-
73-
$propFind->handle(self::TRASHBIN_DELETED_BY_ID, function () use ($node) {
74-
return $node->getDeletedBy()?->getUID();
75-
});
76-
77-
$propFind->handle(self::TRASHBIN_DELETED_BY_DISPLAY_NAME, function () use ($node) {
78-
return $node->getDeletedBy()?->getDisplayName();
79-
});
80-
81-
// Pass the real filename as the DAV display name
82-
$propFind->handle(FilesPlugin::DISPLAYNAME_PROPERTYNAME, function () use ($node) {
83-
return $node->getFilename();
84-
});
85-
86-
$propFind->handle(FilesPlugin::SIZE_PROPERTYNAME, function () use ($node) {
87-
return $node->getSize();
88-
});
89-
90-
$propFind->handle(FilesPlugin::FILEID_PROPERTYNAME, function () use ($node) {
91-
return $node->getFileId();
92-
});
93-
94-
$propFind->handle(FilesPlugin::PERMISSIONS_PROPERTYNAME, function () {
95-
return 'GD'; // read + delete
96-
});
97-
98-
$propFind->handle(FilesPlugin::GETETAG_PROPERTYNAME, function () use ($node) {
99-
// add fake etag, it is only needed to identify the preview image
100-
return $node->getLastModified();
101-
});
102-
103-
$propFind->handle(FilesPlugin::INTERNAL_FILEID_PROPERTYNAME, function () use ($node) {
104-
// add fake etag, it is only needed to identify the preview image
105-
return $node->getFileId();
106-
});
107-
108-
$propFind->handle(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, function () use ($node): string {
109-
return $this->previewManager->isAvailable($node->getFileInfo()) ? 'true' : 'false';
110-
});
111-
112-
$propFind->handle(FilesPlugin::MOUNT_TYPE_PROPERTYNAME, function () {
113-
return '';
114-
});
115-
116-
$propFind->handle(self::TRASHBIN_BACKEND, function () use ($node) {
117-
$fileInfo = $node->getFileInfo();
118-
if (!($fileInfo instanceof ITrashItem)) {
119-
return '';
64+
// Trashbin specific properties
65+
$propFind->handle(self::TRASHBIN_FILENAME, fn () => $node->getFilename());
66+
$propFind->handle(self::TRASHBIN_ORIGINAL_LOCATION, fn () => $node->getOriginalLocation());
67+
$propFind->handle(self::TRASHBIN_TITLE, fn () => $node->getTitle());
68+
$propFind->handle(self::TRASHBIN_DELETION_TIME, fn () => $node->getDeletionTime());
69+
$propFind->handle(self::TRASHBIN_DELETED_BY_ID, fn () => $node->getDeletedBy()?->getUID());
70+
$propFind->handle(self::TRASHBIN_DELETED_BY_DISPLAY_NAME, fn () => $node->getDeletedBy()?->getDisplayName());
71+
$propFind->handle(
72+
self::TRASHBIN_BACKEND,
73+
function () use ($node) {
74+
$fileInfo = $node->getFileInfo();
75+
if (!($fileInfo instanceof ITrashItem)) {
76+
return '';
77+
}
78+
return $fileInfo->getTrashBackend()::class;
12079
}
121-
return $fileInfo->getTrashBackend()::class;
122-
});
80+
);
81+
// Properties mapped from FilesPlugin (most are returned as from the trashbin item itself)
82+
$propFind->handle(
83+
FilesPlugin::DISPLAYNAME_PROPERTYNAME,
84+
fn () => $node->getFilename() // original filename of the ITrash node before deletion
85+
);
86+
$propFind->handle(
87+
FilesPlugin::SIZE_PROPERTYNAME,
88+
fn () => $node->getSize()
89+
);
90+
$propFind->handle(
91+
// User-facing file ID: lets WebDAV clients identify this trashed item in the filesystem view.
92+
FilesPlugin::FILEID_PROPERTYNAME,
93+
fn () => $node->getFileId()
94+
);
95+
$propFind->handle(
96+
FilesPlugin::PERMISSIONS_PROPERTYNAME,
97+
fn () => 'GD' // Permissions: 'G' = read, 'D' = delete
98+
);
99+
$propFind->handle(
100+
FilesPlugin::HAS_PREVIEW_PROPERTYNAME,
101+
fn () => $this->previewManager->isAvailable($node->getFileInfo()) ? 'true' : 'false'
102+
);
103+
$propFind->handle(
104+
FilesPlugin::GETETAG_PROPERTYNAME,
105+
fn () => $node->getLastModified() // Etag based on last modified time of deleted item
106+
);
107+
$propFind->handle(
108+
// Instance-scoped internal file ID: uniquely references this trashbin entry within Nextcloud's storage backend.
109+
FilesPlugin::INTERNAL_FILEID_PROPERTYNAME,
110+
fn () => $node->getFileId() // if storage backends diverge in future can be swapped transparently.
111+
);
112+
$propFind->handle(
113+
// Storage mount type (e.g., personal, groupfolder, or external storage)
114+
FilesPlugin::MOUNT_TYPE_PROPERTYNAME,
115+
fn () => ''; // Trashbin items don't have a mount type currently
116+
);
123117
}
124118

125119
/**
126-
* Set real filename on trashbin download
127-
*
128-
* @param RequestInterface $request
129-
* @param ResponseInterface $response
120+
* Suggest the original filename to the browser for the download.
130121
*/
131122
public function httpGet(RequestInterface $request, ResponseInterface $response): void {
132123
$path = $request->getPath();
133124
$node = $this->server->tree->getNodeForPath($path);
134-
if ($node instanceof ITrash) {
135-
$response->addHeader('Content-Disposition', 'attachment; filename="' . $node->getFilename() . '"');
125+
126+
if (!($node instanceof ITrash)) {
127+
return;
136128
}
129+
130+
$response->addHeader(
131+
'Content-Disposition',
132+
'attachment; filename="' . $node->getFilename() . '"' // TODO: Confirm `filename` value is ASCII; add `filename*=UTF-8` support w/ encoding
133+
);
137134
}
138135

139136
/**
140-
* Check if a user has available space before attempting to
141-
* restore from trashbin unless they have unlimited quota.
137+
* Checks if there is enough available storage space to restore a file, to the destination path, from the trashbin.
138+
*
139+
* This method is called before moving a file out of the trashbin. It returns true if the user
140+
* has sufficient quota to restore the file, or if the quota is unlimited or cannot be determined.
142141
*
143-
* @param string $sourcePath
144-
* @param string $destinationPath
145-
* @return bool
142+
* @param string $sourcePath The path to the file in the trashbin.
143+
* @param string $destinationPath The path where the file will be restored.
144+
* @return bool True if restore is allowed, false otherwise.
146145
*/
147146
public function beforeMove(string $sourcePath, string $destinationPath): bool {
147+
$logger = \OCP\Server::get(LoggerInterface::class);
148+
148149
try {
149150
$node = $this->server->tree->getNodeForPath($sourcePath);
150-
[$destinationDir, ] = Uri\split($destinationPath);
151-
$destinationNodeParent = $this->server->tree->getNodeForPath($destinationDir);
152-
} catch (\Sabre\DAV\Exception $e) {
153-
\OCP\Server::get(LoggerInterface::class)
154-
->error($e->getMessage(), ['app' => 'files_trashbin', 'exception' => $e]);
155-
return true;
156-
}
157151

158-
// Check if a file is being restored before proceeding
159-
if (!$node instanceof ITrash || !$destinationNodeParent instanceof RestoreFolder) {
152+
if (!($node instanceof ITrash)) {
153+
return true;
154+
}
155+
156+
[$destinationParentPath, ] = Uri\split($destinationPath);
157+
$destinationParentNode = $this->server->tree->getNodeForPath($destinationParentPath);
158+
159+
if (!($destinationParentNode instanceof RestoreFolder)) {
160+
return true;
161+
}
162+
163+
} catch (\Sabre\DAV\Exception $e) {
164+
$logger->error('Failed to move trashbin file', [
165+
'app' => 'files_trashbin',
166+
'exception' => $e
167+
]);
160168
return true;
161169
}
162170

163171
$fileInfo = $node->getFileInfo();
164-
if (!$fileInfo instanceof ITrashItem) {
172+
173+
if (!($fileInfo instanceof ITrashItem)) {
165174
return true;
166175
}
167-
$restoreFolder = dirname($fileInfo->getOriginalLocation());
168-
$freeSpace = $this->view->free_space($restoreFolder);
169-
if ($freeSpace === FileInfo::SPACE_NOT_COMPUTED
176+
177+
$freeSpace = $this->view->free_space($destinationParentPath);
178+
179+
if (
180+
$freeSpace === FileInfo::SPACE_NOT_COMPUTED
170181
|| $freeSpace === FileInfo::SPACE_UNKNOWN
171-
|| $freeSpace === FileInfo::SPACE_UNLIMITED) {
182+
|| $freeSpace === FileInfo::SPACE_UNLIMITED
183+
) {
184+
// No relevant quota
172185
return true;
173186
}
174-
$filesize = $fileInfo->getSize();
175-
if ($freeSpace < $filesize) {
187+
188+
$fileSize = $fileInfo->getSize();
189+
190+
if ($freeSpace - $fileSize < self::TRASHBIN_RESTORE_SPACE_SAFETY_MARGIN) {
191+
// Not enough space, block restore
176192
$this->server->httpResponse->setStatus(507);
193+
$logger->debug('Failed to move trashbin file', [
194+
'app' => 'files_trashbin',
195+
'reason' => 'Insufficient space available to restore safely'
196+
]);
177197
return false;
178198
}
179199

0 commit comments

Comments
 (0)