Skip to content

Commit 4272016

Browse files
committed
Add occ command to repair mtime
Signed-off-by: Louis Chemineau <louis@chmn.me>
1 parent b40481e commit 4272016

File tree

11 files changed

+395
-2
lines changed

11 files changed

+395
-2
lines changed

apps/files/appinfo/info.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
<command>OCA\Files\Command\TransferOwnership</command>
3636
<command>OCA\Files\Command\ScanAppData</command>
3737
<command>OCA\Files\Command\RepairTree</command>
38+
<command>OCA\Files\Command\RepairMtime</command>
3839
</commands>
3940

4041
<activity>
@@ -67,4 +68,4 @@
6768
<personal>OCA\Files\Settings\PersonalSettings</personal>
6869
</settings>
6970

70-
</info>
71+
</info>

apps/files/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
'OCA\\Files\\Collaboration\\Resources\\Listener' => $baseDir . '/../lib/Collaboration/Resources/Listener.php',
2828
'OCA\\Files\\Collaboration\\Resources\\ResourceProvider' => $baseDir . '/../lib/Collaboration/Resources/ResourceProvider.php',
2929
'OCA\\Files\\Command\\DeleteOrphanedFiles' => $baseDir . '/../lib/Command/DeleteOrphanedFiles.php',
30+
'OCA\\Files\\Command\\RepairMtime' => $baseDir . '/../lib/Command/RepairMtime.php',
3031
'OCA\\Files\\Command\\RepairTree' => $baseDir . '/../lib/Command/RepairTree.php',
3132
'OCA\\Files\\Command\\Scan' => $baseDir . '/../lib/Command/Scan.php',
3233
'OCA\\Files\\Command\\ScanAppData' => $baseDir . '/../lib/Command/ScanAppData.php',

apps/files/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class ComposerStaticInitFiles
4242
'OCA\\Files\\Collaboration\\Resources\\Listener' => __DIR__ . '/..' . '/../lib/Collaboration/Resources/Listener.php',
4343
'OCA\\Files\\Collaboration\\Resources\\ResourceProvider' => __DIR__ . '/..' . '/../lib/Collaboration/Resources/ResourceProvider.php',
4444
'OCA\\Files\\Command\\DeleteOrphanedFiles' => __DIR__ . '/..' . '/../lib/Command/DeleteOrphanedFiles.php',
45+
'OCA\\Files\\Command\\RepairMtime' => __DIR__ . '/..' . '/../lib/Command/RepairMtime.php',
4546
'OCA\\Files\\Command\\RepairTree' => __DIR__ . '/..' . '/../lib/Command/RepairTree.php',
4647
'OCA\\Files\\Command\\Scan' => __DIR__ . '/..' . '/../lib/Command/Scan.php',
4748
'OCA\\Files\\Command\\ScanAppData' => __DIR__ . '/..' . '/../lib/Command/ScanAppData.php',
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
<?php
2+
/**
3+
* @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me>
4+
*
5+
* @author Louis Chemineau <louis@chmn.me>
6+
*
7+
* @license AGPL-3.0
8+
*
9+
* This code is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU Affero General Public License, version 3,
11+
* as published by the Free Software Foundation.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU Affero General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Affero General Public License, version 3,
19+
* along with this program. If not, see <http://www.gnu.org/licenses/>
20+
*
21+
*/
22+
namespace OCA\Files\Command;
23+
24+
use Symfony\Component\Console\Helper\Table;
25+
use Symfony\Component\Console\Input\InputArgument;
26+
use Symfony\Component\Console\Input\InputInterface;
27+
use Symfony\Component\Console\Input\InputOption;
28+
use Symfony\Component\Console\Output\OutputInterface;
29+
use OC\ForbiddenException;
30+
use OC\Core\Command\Base;
31+
use OC\Core\Command\InterruptedException;
32+
use OC\Files\Search\SearchComparison;
33+
use OC\Files\Search\SearchOrder;
34+
use OC\Files\Search\SearchQuery;
35+
use OCP\Files\Search\ISearchComparison;
36+
use OCP\Files\Search\ISearchOrder;
37+
use OCP\Files\NotFoundException;
38+
use OCP\Files\IRootFolder;
39+
use OCP\IUserManager;
40+
use OCP\IDBConnection;
41+
42+
class RepairMtime extends Base {
43+
private IUserManager $userManager;
44+
private IRootFolder $rootFolder;
45+
protected IDBConnection $connection;
46+
47+
protected float $execTime = 0;
48+
protected int $filesCounter = 0;
49+
50+
public function __construct(IDBConnection $connection, IUserManager $userManager, IRootFolder $rootFolder) {
51+
$this->connection = $connection;
52+
$this->userManager = $userManager;
53+
$this->rootFolder = $rootFolder;
54+
parent::__construct();
55+
}
56+
57+
protected function configure() {
58+
parent::configure();
59+
60+
$this
61+
->setName('files:repair-mtime')
62+
->setDescription('Repair files\' mtime')
63+
->addArgument(
64+
'user_id',
65+
InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
66+
'will repair mtime for all files of the given user(s)'
67+
)
68+
->addOption(
69+
'all',
70+
null,
71+
InputOption::VALUE_NONE,
72+
'will repair all files of all known users'
73+
)
74+
->addOption(
75+
'dry-run',
76+
null,
77+
InputOption::VALUE_NONE,
78+
'will list files instead of repairing them'
79+
);
80+
}
81+
82+
protected function execute(InputInterface $input, OutputInterface $output): int {
83+
if ($input->getOption('all')) {
84+
$users = $this->userManager->search('');
85+
} else {
86+
$users = $input->getArgument('user_id');
87+
}
88+
89+
# check quantity of users to be process and show it on the command line
90+
$users_total = count($users);
91+
if ($users_total === 0) {
92+
$output->writeln('<error>Please specify the user id to scan, --all to scan for all users</error>');
93+
return 1;
94+
}
95+
96+
$this->initTools();
97+
98+
$user_count = 0;
99+
foreach ($users as $user) {
100+
if (is_object($user)) {
101+
$user = $user->getUID();
102+
}
103+
++$user_count;
104+
if ($this->userManager->userExists($user)) {
105+
$this->repairMtimeForUser(
106+
$user,
107+
$input->getOption('dry-run'),
108+
$output,
109+
);
110+
} else {
111+
$output->writeln("<error>Unknown user $user_count $user</error>");
112+
}
113+
114+
try {
115+
$this->abortIfInterrupted();
116+
} catch (InterruptedException $e) {
117+
break;
118+
}
119+
}
120+
121+
$this->presentStats($output, $input->getOption('dry-run'));
122+
return 0;
123+
}
124+
125+
public function repairMtimeForUser(string $userId, bool $dryRun, OutputInterface $output): void {
126+
$userFolder = $this->rootFolder->getUserFolder($userId);
127+
$user = $this->userManager->get($userId);
128+
129+
$offset = 0;
130+
131+
do {
132+
$invalidFiles = $userFolder
133+
->search(
134+
new SearchQuery(
135+
new SearchComparison(ISearchComparison::COMPARE_LESS_THAN_EQUAL, 'mtime', 86400),
136+
0, // 0 = no limits.
137+
$offset,
138+
[new SearchOrder(ISearchOrder::DIRECTION_DESCENDING, 'mtime')],
139+
$user
140+
)
141+
);
142+
143+
$offset += count($invalidFiles);
144+
145+
$this->connection->beginTransaction();
146+
147+
foreach ($invalidFiles as $file) {
148+
$this->filesCounter++;
149+
150+
try {
151+
$filePath = $file->getPath();
152+
$fileId = $file->getId();
153+
$fileStorage = $file->getStorage();
154+
155+
// Default new mtime to the current time.
156+
$mtime = null;
157+
158+
if ($fileStorage->instanceOfStorage("OC\Files\ObjectStore\ObjectStoreStorage")) {
159+
// Get LastModified property for S3 as primary storage.
160+
/** @var \OC\Files\ObjectStore\ObjectStoreStorage $fileStorage */
161+
$headResult = $fileStorage->getObjectStore()->headObject("urn:oid:$fileId");
162+
$date = \DateTime::createFromFormat(\DateTimeInterface::ISO8601, $headResult->get('LastModified'));
163+
$mtime = $date->getTimestamp();
164+
} elseif ($file->getStorage()->instanceOfStorage("OCA\Files_External\Lib\Storage\AmazonS3")) {
165+
// Get LastModified property for S3 as external storage.
166+
/** @var \OCA\Files_External\Lib\Storage\AmazonS3 $fileStorage */
167+
$headResult = $fileStorage->headObject("urn:oid:$fileId");
168+
$date = \DateTime::createFromFormat(\DateTimeInterface::ISO8601, $headResult['LastModified']);
169+
$mtime = $date->getTimestamp();
170+
}
171+
172+
if ($dryRun) {
173+
$output->writeln("- Found '$filePath', would set the mtime to $mtime.", OutputInterface::VERBOSITY_VERBOSE);
174+
} else {
175+
$file->touch($mtime);
176+
$output->writeln("- Fixed $filePath", OutputInterface::VERBOSITY_VERBOSE);
177+
}
178+
} catch (ForbiddenException $e) {
179+
$output->writeln("<error>Home storage for user $userId not writable</error>");
180+
$output->writeln('Make sure you\'re running the scan command only as the user the web server runs as');
181+
} catch (InterruptedException $e) {
182+
# exit the function if ctrl-c has been pressed
183+
$output->writeln('Interrupted by user');
184+
} catch (NotFoundException $e) {
185+
$output->writeln('<error>Path not found: ' . $e->getMessage() . '</error>');
186+
} catch (\Exception $e) {
187+
$output->writeln('<error>Exception during scan: ' . $e->getMessage() . '</error>');
188+
$output->writeln('<error>' . $e->getTraceAsString() . '</error>');
189+
}
190+
}
191+
192+
$this->connection->commit();
193+
} while (count($invalidFiles) > 0);
194+
}
195+
196+
/**
197+
* Initialises some useful tools for the Command
198+
*/
199+
protected function initTools(): void {
200+
// Start the timer
201+
$this->execTime = -microtime(true);
202+
// Convert PHP errors to exceptions
203+
set_error_handler([$this, 'exceptionErrorHandler'], E_ALL);
204+
}
205+
206+
/**
207+
* Processes PHP errors as exceptions in order to be able to keep track of problems
208+
*
209+
* @see https://www.php.net/manual/en/function.set-error-handler.php
210+
*
211+
* @param int $severity the level of the error raised
212+
* @param string $message
213+
* @param string $file the filename that the error was raised in
214+
* @param int $line the line number the error was raised
215+
*
216+
* @throws \ErrorException
217+
*/
218+
public function exceptionErrorHandler(int $severity, string $message, string $file, int $line) {
219+
if (!(error_reporting() & $severity)) {
220+
// This error code is not included in error_reporting
221+
return;
222+
}
223+
throw new \ErrorException($message, 0, $severity, $file, $line);
224+
}
225+
226+
protected function presentStats(OutputInterface $output, bool $dryRun) {
227+
// Stop the timer
228+
$this->execTime += microtime(true);
229+
230+
$columnName = 'Fixed files';
231+
if ($dryRun) {
232+
$columnName = 'Found files';
233+
}
234+
235+
$table = new Table($output);
236+
$table
237+
->setHeaders([$columnName, 'Elapsed time'])
238+
->setRows([[$this->filesCounter, $this->formatExecTime()]])
239+
->render();
240+
}
241+
242+
/**
243+
* Formats microtime into a human readable format
244+
*/
245+
protected function formatExecTime(): string {
246+
$secs = round($this->execTime);
247+
# convert seconds into HH:MM:SS form
248+
return sprintf('%02d:%02d:%02d', ($secs / 3600), ($secs / 60 % 60), $secs % 60);
249+
}
250+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
/**
3+
* Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com>
4+
* Copyright (c) 2014-2015 Olivier Paroz owncloud@oparoz.com
5+
* This file is licensed under the Affero General Public License version 3 or
6+
* later.
7+
* See the COPYING-README file.
8+
*/
9+
10+
namespace OCA\Files\Tests\Command;
11+
12+
use OCP\IDBConnection;
13+
use OCP\IUserManager;
14+
use OCP\Files\IRootFolder;
15+
use \OCA\Files\Command\RepairMtime;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Output\OutputInterface;
18+
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
19+
20+
/**
21+
* Tests for the repairing invalid mtime.
22+
*
23+
* @group DB
24+
*
25+
* @see \OCA\Files\Command\RepairMtime
26+
*/
27+
class RepairMtimeTest extends \Test\TestCase {
28+
private IDBConnection $connection;
29+
private IUserManager $userManager;
30+
private IRootFolder $rootFolder;
31+
32+
private RepairMtime $repairMtime;
33+
34+
private InputInterface $inputMock;
35+
private InputInterface $inputDryRunMock;
36+
37+
protected function setUp(): void {
38+
parent::setUp();
39+
40+
$this->connection = \OC::$server->get(IDBConnection::class);
41+
$this->userManager = \OC::$server->get(IUserManager::class);
42+
$this->rootFolder = \OC::$server->get(IRootFolder::class);
43+
44+
$this->repairMtime = new \OCA\Files\Command\RepairMtime($this->connection, $this->userManager, $this->rootFolder);
45+
46+
$this->inputMock = $this->createMock(InputInterface::class);
47+
$this->inputMock
48+
->expects($this->any())
49+
->method('getArgument')
50+
->willReturnMap([['user_id', ['admin']]]);
51+
$this->inputMock
52+
->expects($this->any())
53+
->method('getOption')
54+
->willReturnMap([['path', ''], ['dry-run', false]]);
55+
56+
$this->inputDryRunMock = $this->createMock(InputInterface::class);
57+
$this->inputDryRunMock
58+
->expects($this->any())
59+
->method('getArgument')
60+
->willReturnMap([['user_id', ['admin']]]);
61+
$this->inputDryRunMock
62+
->expects($this->any())
63+
->method('getOption')
64+
->willReturnMap([['path', ''], ['dry-run', true]]);
65+
}
66+
67+
public function testRepairMtimeLocalFile() {
68+
$adminFolder = $this->rootFolder->getUserFolder('admin');
69+
70+
for ($i = 0; $i < 10; $i++) {
71+
$adminFolder
72+
->newFile("file_nb_$i.txt", "file_content_$i")
73+
->touch(0);
74+
}
75+
76+
$found = 0;
77+
$fixed = 0;
78+
79+
$outputMock = $this->createMock(OutputInterface::class);
80+
$outputMock
81+
->expects($this->any())
82+
->method('writeln')
83+
->with(
84+
$this->callback(function ($subject) use (&$found, &$fixed) {
85+
if (str_contains($subject, "- Found")) {
86+
$found++;
87+
} elseif (str_contains($subject, "- Fixed")) {
88+
$fixed++;
89+
}
90+
return true;
91+
}
92+
));
93+
$outputMock
94+
->expects($this->any())
95+
->method('getFormatter')
96+
->willReturn($this->createMock(OutputFormatterInterface::class));
97+
98+
$this->repairMtime->run($this->inputDryRunMock, $outputMock);
99+
$this->assertEquals($found, 10);
100+
$this->assertEquals($fixed, 0);
101+
102+
$found = 0;
103+
$fixed = 0;
104+
$this->repairMtime->run($this->inputMock, $outputMock);
105+
$this->assertEquals($found, 0);
106+
$this->assertEquals($fixed, 10);
107+
108+
$found = 0;
109+
$fixed = 0;
110+
$this->repairMtime->run($this->inputDryRunMock, $outputMock);
111+
$this->assertEquals($found, 0);
112+
$this->assertEquals($fixed, 0);
113+
}
114+
}

0 commit comments

Comments
 (0)