diff --git a/changelog/unreleased/39136 b/changelog/unreleased/39136 new file mode 100644 index 000000000000..8f8fe857680b --- /dev/null +++ b/changelog/unreleased/39136 @@ -0,0 +1,5 @@ +Enhancement: Add console command to move a user's home folder + +occ user:move-home + +https://github.com/owncloud/core/pull/39136 \ No newline at end of file diff --git a/core/Command/User/MoveHome.php b/core/Command/User/MoveHome.php new file mode 100644 index 000000000000..67a144bffd08 --- /dev/null +++ b/core/Command/User/MoveHome.php @@ -0,0 +1,137 @@ + + * + * @copyright Copyright (c) 2021, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OC\Core\Command\User; + +use InvalidArgumentException; +use OC\User\AccountMapper; +use OCP\IUser; +use OCP\IUserManager; +use RuntimeException; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class MoveHome extends Command { + + /** + * @var IUserManager + */ + private $userManager; + + public function __construct( + IUserManager $userManager + ) { + parent::__construct(); + $this->userManager = $userManager; + } + + protected function configure() { + $this->setName('user:move-home') + ->setDescription('Move a user\'s home folder to a new location.') + ->addArgument('user_id', InputArgument::REQUIRED, 'Id of the user whose home folder is to be moved. ') + ->addArgument('new_location', InputArgument::REQUIRED, 'Absolute path to the parent folder of the new location of the home folder.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) { + $user = $this->getUser($input); + $userId = $user->getUID(); + $oldHome = $user->getHome(); + + $newLocation = $this->getNewLocationForUser($input, $user); + $output->writeln("Move $userId from $oldHome to $newLocation"); + + # disable user + $output->writeln("Disabling user ...."); + $user->setEnabled(false); + + # copy files + $output->writeln("Syncing files from $oldHome/ to $newLocation ..."); + $this->rsync($oldHome, $newLocation); + + # set new home folder + $output->writeln("Updating user home path ...."); + $user->setHome($newLocation); + + # enable user + $output->writeln("Enabling user ...."); + $user->setEnabled(true); + + # notify admin about necessary LDAP change + $output->writeln(''); + $output->writeln("Files for user $userId have been moved to $newLocation"); + $output->writeln("In case you are using LDAP Home Folder please update the property in your LDAP directory for this user"); + $output->writeln(''); + + $output->writeln(''); + $output->writeln("The old home folder can now be deleted (rm -rf $oldHome)"); + $output->writeln(''); + + return 0; + } + + protected function getUser(InputInterface $input): IUser { + $userId = $input->getArgument('user_id'); + $user = $this->userManager->get($userId); + if ($user === null) { + throw new InvalidArgumentException('User is not known.'); + } + + return $user; + } + + protected function getNewLocationForUser(InputInterface $input, IUser $user): string { + $newLocation = $input->getArgument('new_location'); + if (!is_dir($newLocation)) { + throw new InvalidArgumentException('New root location has to exist'); + } + + $oldHome = $user->getHome(); + if (!is_dir($oldHome)) { + throw new InvalidArgumentException("Current user home $oldHome does not exist. Not mounted? Non local storage?"); + } + $userFolder = basename($oldHome); + $newLocation .= "/$userFolder"; + + mkdir($newLocation); + if ($this->is_dir_empty($newLocation) !== true) { + throw new InvalidArgumentException("New user folder $newLocation is either not readable or not empty"); + } + + return realpath($newLocation); + } + + private function is_dir_empty($dir): ?bool { + if (!is_readable($dir)) { + return null; + } + return (\count(scandir($dir)) === 2); + } + + private function rsync(string $oldHome, string $newLocation): void { + exec("rsync -aAX $oldHome/ $newLocation", $output, $return_var); + if ($return_var !== 0) { + throw new RuntimeException("Copying files failed: \n $output"); + } + } +} diff --git a/core/register_command.php b/core/register_command.php index abbd0d9f28ed..04feb351d187 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -190,6 +190,7 @@ )); $application->add(new OC\Core\Command\User\Setting(\OC::$server->getUserManager(), \OC::$server->getConfig(), \OC::$server->getDatabaseConnection())); $application->add(new OC\Core\Command\User\Modify(\OC::$server->getUserManager(), \OC::$server->getMailer())); + $application->add(new OC\Core\Command\User\MoveHome(\OC::$server->getUserManager())); $application->add(new OC\Core\Command\User\SyncBackend(\OC::$server->getAccountMapper(), \OC::$server->getConfig(), \OC::$server->getUserManager(), \OC::$server->getLogger())); $application->add(new OC\Core\Command\Group\Add(\OC::$server->getGroupManager())); diff --git a/lib/private/User/DeletedUser.php b/lib/private/User/DeletedUser.php index a69881926119..9b046e300c45 100644 --- a/lib/private/User/DeletedUser.php +++ b/lib/private/User/DeletedUser.php @@ -158,6 +158,10 @@ public function getHome() { throw new \Exception("Not Implemented", 1); } + public function setHome(string $newLocation) { + throw new \Exception("Not Implemented", 1); + } + public function getBackendClassName() { throw new \Exception("Not Implemented", 1); } diff --git a/lib/private/User/RemoteUser.php b/lib/private/User/RemoteUser.php index 522b4e1522cd..bcfc0032922a 100644 --- a/lib/private/User/RemoteUser.php +++ b/lib/private/User/RemoteUser.php @@ -110,6 +110,9 @@ public function getHome() { return ''; } + public function setHome(string $newLocation) { + } + /** * @inheritdoc */ diff --git a/lib/private/User/User.php b/lib/private/User/User.php index 57c7dc85db66..ce742e921c2d 100644 --- a/lib/private/User/User.php +++ b/lib/private/User/User.php @@ -339,6 +339,10 @@ public function getHome() { return $this->account->getHome(); } + public function setHome(string $newLocation) { + $this->account->setHome($newLocation); + } + /** * Get the name of the backend class the user is connected with * diff --git a/lib/public/IUser.php b/lib/public/IUser.php index d844b7a72510..ee2548d68cee 100644 --- a/lib/public/IUser.php +++ b/lib/public/IUser.php @@ -121,6 +121,14 @@ public function setPassword($password, $recoveryPassword = null); */ public function getHome(); + /** + * set the users home folder to mount + * + * @return void + * @since 10.9.0 + */ + public function setHome(string $newLocation); + /** * Get the name of the backend class the user is connected with * diff --git a/tests/Core/Command/User/MoveHomeTest.php b/tests/Core/Command/User/MoveHomeTest.php new file mode 100644 index 000000000000..880b8410c0cb --- /dev/null +++ b/tests/Core/Command/User/MoveHomeTest.php @@ -0,0 +1,95 @@ + + * + * @copyright Copyright (c) 2021, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace Tests\Core\Command\User; + +use OC\Core\Command\User\MoveHome; +use OC\Files\ObjectStore\ObjectStoreStorage; +use OCP\IUser; +use Symfony\Component\Console\Tester\CommandTester; +use Test\TestCase; + +/** + * Class MoveHomeTest + * + * @group DB + * @package Tests\Core\Command\User + */ +class MoveHomeTest extends TestCase { + /** + * @var CommandTester + */ + private $commandTester; + /** + * @var string + */ + private $newLocation; + /** + * @var bool|IUser + */ + private $user; + + protected function setUp(): void { + parent::setUp(); + + $command = new MoveHome(\OC::$server->getUserManager()); + $this->commandTester = new CommandTester($command); + + # create a new location in the data folder + $newLocation = \OC::$server->getSystemConfig()->getValue('datadirectory'); + $this->newLocation = uniqid($newLocation . '/'); + mkdir($this->newLocation); + + # test user + $this->user = \OC::$server->getUserManager()->createUser('test-user', 'test-user'); + } + + protected function tearDown(): void { + $this->user->delete(); + parent::tearDown(); + + rmdir($this->newLocation); + } + + public function test(): void { + \OC::$server->getUserSession()->login('test-user', 'test-user'); + $file = \OC::$server->getUserFolder($this->user->getUID())->newFile('hello.txt'); + $file->putContent('1234567890'); + + if ($file->getStorage()->instanceOfStorage(ObjectStoreStorage::class)) { + $this->markTestSkipped('user:move-home is only implemented for posix file systems'); + } + + $exitCode = $this->commandTester->execute([ + 'user_id' => 'test-user', + 'new_location' => $this->newLocation + ]); + + # assert command output + self::assertEquals(0, $exitCode, $this->commandTester->getDisplay()); + + # assert that the new location holds the file + self::assertFileExists("{$this->newLocation}/test-user/files/hello.txt"); + + # assert the user has a new home set + self::assertEquals("{$this->newLocation}/test-user", $this->user->getHome()); + } +}