Skip to content

Commit 688ba2f

Browse files
committed
fix(files_trashbin): Expire trashbin items when space is needed
Signed-off-by: Kent Delante <kent.delante@proton.me>
1 parent a4d4226 commit 688ba2f

File tree

4 files changed

+190
-5
lines changed

4 files changed

+190
-5
lines changed

apps/files_trashbin/lib/Command/ExpireTrash.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ protected function configure() {
4545
}
4646

4747
protected function execute(InputInterface $input, OutputInterface $output): int {
48+
$minAge = $this->expiration->getMinAgeAsTimestamp();
4849
$maxAge = $this->expiration->getMaxAgeAsTimestamp();
49-
if (!$maxAge) {
50+
if ($minAge === false && $maxAge === false) {
5051
$output->writeln('Auto expiration is configured - keeps files and folders in the trash bin for 30 days and automatically deletes anytime after that if space is needed (note: files may not be deleted if space is not needed)');
5152
return 1;
5253
}

apps/files_trashbin/lib/Expiration.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,20 @@ public function isExpired($timestamp, $quotaExceeded = false) {
9393
return $isOlderThanMax || $isMinReached;
9494
}
9595

96+
/**
97+
* Get minimal retention obligation as a timestamp
98+
*
99+
* @return int|false
100+
*/
101+
public function getMinAgeAsTimestamp() {
102+
$minAge = false;
103+
if ($this->isEnabled() && $this->minAge !== self::NO_OBLIGATION) {
104+
$time = $this->timeFactory->getTime();
105+
$minAge = $time - ($this->minAge * 86400);
106+
}
107+
return $minAge;
108+
}
109+
96110
/**
97111
* @return bool|int
98112
*/

apps/files_trashbin/lib/Trashbin.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -907,14 +907,18 @@ protected static function deleteFiles(array $files, string $user, int|float $ava
907907
public static function deleteExpiredFiles($files, $user) {
908908
/** @var Expiration $expiration */
909909
$expiration = Server::get(Expiration::class);
910-
$size = 0;
910+
$totalSize = 0;
911911
$count = 0;
912+
$trashbinSize = self::getTrashbinSize($user);
913+
$freeSpace = self::calculateFreeSpace($trashbinSize, $user);
912914
foreach ($files as $file) {
913915
$timestamp = $file['mtime'];
914916
$filename = $file['name'];
915-
if ($expiration->isExpired($timestamp)) {
917+
if ($expiration->isExpired($timestamp, $freeSpace <= 0)) {
916918
try {
917-
$size += self::delete($filename, $user, $timestamp);
919+
$size = self::delete($filename, $user, $timestamp);
920+
$freeSpace += $size;
921+
$totalSize += $size;
918922
$count++;
919923
} catch (NotPermittedException $e) {
920924
Server::get(LoggerInterface::class)->warning('Removing "' . $filename . '" from trashbin failed for user "{user}"',
@@ -937,7 +941,7 @@ public static function deleteExpiredFiles($files, $user) {
937941
}
938942
}
939943

940-
return [$size, $count];
944+
return [$totalSize, $count];
941945
}
942946

943947
/**
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
/**
3+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
4+
* SPDX-License-Identifier: AGPL-3.0-only
5+
*/
6+
namespace OCA\Files_Trashbin\Tests\Command;
7+
8+
use OC\Files\View;
9+
use OCA\Files_Trashbin\Command\ExpireTrash;
10+
use OCA\Files_Trashbin\Expiration;
11+
use OCA\Files_Trashbin\Helper;
12+
use OCP\AppFramework\Utility\ITimeFactory;
13+
use OCP\IConfig;
14+
use OCP\IUser;
15+
use OCP\IUserManager;
16+
use OCP\Server;
17+
use Psr\Log\LoggerInterface;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
use Test\TestCase;
21+
22+
/**
23+
* Class ExpireTrashTest
24+
*
25+
* @group DB
26+
*
27+
* @package OCA\Files_Trashbin\Tests\Command
28+
*/
29+
class ExpireTrashTest extends TestCase {
30+
private Expiration $expiration;
31+
private ExpireTrash $command;
32+
private View $userView;
33+
private IConfig $config;
34+
private IUserManager $userManager;
35+
private IUser $user;
36+
private ITimeFactory $timeFactory;
37+
38+
39+
protected function setUp(): void {
40+
parent::setUp();
41+
42+
$this->config = Server::get(IConfig::class);
43+
$this->timeFactory = $this->createMock(ITimeFactory::class);
44+
$this->expiration = Server::get(Expiration::class);
45+
$this->invokePrivate($this->expiration, 'timeFactory', [$this->timeFactory]);
46+
47+
$userId = self::getUniqueID('user');
48+
$this->userManager = Server::get(IUserManager::class);
49+
$this->user = $this->userManager->createUser($userId, $userId);
50+
51+
$this->loginAsUser($userId);
52+
$this->userView = new View('/' . $userId . '/files/');
53+
}
54+
55+
protected function tearDown(): void {
56+
$view = new View('/' . $this->user->getUID());
57+
$view->deleteAll('files');
58+
$view->deleteAll('files_trashbin');
59+
60+
$this->logout();
61+
62+
if (isset($this->user)) {
63+
$this->user->delete();
64+
}
65+
66+
parent::tearDown();
67+
}
68+
69+
/**
70+
* @dataProvider retentionObligationProvider
71+
*/
72+
public function testRetentionObligation(string $obligation, string $quota, int $elapsed, int $fileSize, bool $shouldExpire): void {
73+
if (!empty($this->config->getSystemValue('objectstore'))) {
74+
// Object storage scanner and cache behave differently causing test to fail in some cases
75+
$this->markTestSkipped('Skip test on object storage');
76+
}
77+
78+
$this->config->setSystemValues(['trashbin_retention_obligation' => $obligation]);
79+
$this->expiration->setRetentionObligation($obligation);
80+
81+
$this->command = new ExpireTrash(
82+
Server::get(LoggerInterface::class),
83+
Server::get(IUserManager::class),
84+
$this->expiration
85+
);
86+
87+
$this->user->setQuota($quota);
88+
89+
$file = 'foo.txt';
90+
$this->userView->touch($file);
91+
$handle = $this->userView->fopen($file, 'w');
92+
if (is_resource($handle)) {
93+
fseek($handle, $fileSize, SEEK_CUR);
94+
fwrite($handle, 'a');
95+
fclose($handle);
96+
}
97+
$filemtime = $this->userView->filemtime($file);
98+
$this->timeFactory->expects($this->any())
99+
->method('getTime')
100+
->willReturn($filemtime + $elapsed);
101+
$this->userView->unlink($file);
102+
[$storage,] = $this->userView->resolvePath($file);
103+
$storage->getScanner()->scan('');
104+
105+
$userId = $this->user->getUID();
106+
$trashFiles = Helper::getTrashFiles('/', $userId);
107+
$this->assertEquals(1, count($trashFiles));
108+
109+
$outputInterface = $this->createMock(OutputInterface::class);
110+
$inputInterface = $this->createMock(InputInterface::class);
111+
$inputInterface->expects($this->any())
112+
->method('getArgument')
113+
->with('user_id')
114+
->willReturn([$userId]);
115+
116+
$this->invokePrivate($this->command, 'execute', [$inputInterface, $outputInterface]);
117+
118+
$trashFiles = Helper::getTrashFiles('/', $userId);
119+
$this->assertEquals($shouldExpire ? 0 : 1, count($trashFiles));
120+
}
121+
122+
public function retentionObligationProvider(): array {
123+
$megabyte = 1048576; // 1024 * 1024
124+
$hour = 3600; // 60 * 60
125+
126+
$oneDay = 24 * $hour;
127+
$fiveDays = 24 * 5 * $hour;
128+
$tenDays = 24 * 10 * $hour;
129+
$elevenDays = 24 * 11 * $hour;
130+
131+
return [
132+
['disabled', '20 MB', 0, 1 * $megabyte, false],
133+
134+
['auto', '20 MB', 0, 5 * $megabyte, false],
135+
['auto', '20 MB', 0, 21 * $megabyte, true],
136+
137+
['0, auto', '20 MB', 0, 21 * $megabyte, true],
138+
['0, auto', '20 MB', $oneDay, 5 * $megabyte, false],
139+
['0, auto', '20 MB', $oneDay, 19 * $megabyte, true],
140+
['0, auto', '20 MB', 0, 19 * $megabyte, true],
141+
142+
['auto, 0', '20 MB', $oneDay, 19 * $megabyte, true],
143+
['auto, 0', '20 MB', $oneDay, 21 * $megabyte, true],
144+
['auto, 0', '20 MB', 0, 5 * $megabyte, false],
145+
['auto, 0', '20 MB', 0, 19 * $megabyte, true],
146+
147+
['1, auto', '20 MB', 0, 5 * $megabyte, false],
148+
['1, auto', '20 MB', $fiveDays, 5 * $megabyte, false],
149+
['1, auto', '20 MB', $fiveDays, 21 * $megabyte, true],
150+
151+
['auto, 1', '20 MB', 0, 21 * $megabyte, true],
152+
['auto, 1', '20 MB', 0, 5 * $megabyte, false],
153+
['auto, 1', '20 MB', $fiveDays, 5 * $megabyte, true],
154+
['auto, 1', '20 MB', $oneDay, 5 * $megabyte, false],
155+
156+
['2, 10', '20 MB', $fiveDays, 5 * $megabyte, false],
157+
['2, 10', '20 MB', $fiveDays, 20 * $megabyte, true],
158+
['2, 10', '20 MB', $elevenDays, 5 * $megabyte, true],
159+
160+
['10, 2', '20 MB', $fiveDays, 5 * $megabyte, false],
161+
['10, 2', '20 MB', $fiveDays, 21 * $megabyte, false],
162+
['10, 2', '20 MB', $tenDays, 5 * $megabyte, false],
163+
['10, 2', '20 MB', $elevenDays, 5 * $megabyte, true]
164+
];
165+
}
166+
}

0 commit comments

Comments
 (0)