33declare (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 */
99namespace OCA \Files_Trashbin \Sabre ;
2222use Sabre \HTTP \ResponseInterface ;
2323use 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+ */
2531class 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