diff --git a/appinfo/routes.php b/appinfo/routes.php index aa84a70a..19751966 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -33,6 +33,7 @@ ['name' => 'Api#migrators', 'url' => '/api/v{apiVersion}/migrators', 'verb' => 'GET', 'requirements' => $requirements], ['name' => 'Api#status', 'url' => '/api/v{apiVersion}/status', 'verb' => 'GET', 'requirements' => $requirements], ['name' => 'Api#cancel', 'url' => '/api/v{apiVersion}/cancel', 'verb' => 'PUT', 'requirements' => $requirements], + ['name' => 'Api#exportable', 'url' => '/api/v{apiVersion}/export', 'verb' => 'GET', 'requirements' => $requirements], ['name' => 'Api#export', 'url' => '/api/v{apiVersion}/export', 'verb' => 'POST', 'requirements' => $requirements], ['name' => 'Api#import', 'url' => '/api/v{apiVersion}/import', 'verb' => 'POST', 'requirements' => $requirements], ], diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 3c7c907e..766e485f 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -29,6 +29,7 @@ use OCA\UserMigration\AppInfo\Application; use OCA\UserMigration\Db\UserExport; use OCA\UserMigration\Db\UserImport; +use OCA\UserMigration\NotExportableException; use OCA\UserMigration\Service\UserMigrationService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; @@ -171,19 +172,9 @@ public function cancel(): DataResponse { } /** - * @NoAdminRequired - * @NoSubAdminRequired - * @PasswordConfirmationRequired - * * @throws OCSException */ - public function export(array $migrators): DataResponse { - $user = $this->userSession->getUser(); - - if (empty($user)) { - throw new OCSException('No user currently logged in'); - } - + private function checkMigrators(array $migrators): void { /** @var string[] $availableMigrators */ $availableMigrators = array_map( fn (IMigrator $migrator) => $migrator->getId(), @@ -195,6 +186,17 @@ public function export(array $migrators): DataResponse { throw new OCSException("Requested migrator \"$migrator\" not available"); } } + } + + /** + * @throws OCSException + */ + private function checkJobAndGetUser(): IUser { + $user = $this->userSession->getUser(); + + if (empty($user)) { + throw new OCSException('No user currently logged in'); + } try { $job = $this->migrationService->getCurrentJob($user); @@ -206,6 +208,54 @@ public function export(array $migrators): DataResponse { throw new OCSException('User migration operation already queued'); } + return $user; + } + + /** + * @NoAdminRequired + * @NoSubAdminRequired + * + * @throws OCSException + */ + public function exportable(?array $migrators): DataResponse { + $user = $this->checkJobAndGetUser(); + + if (!is_null($migrators)) { + $this->checkMigrators($migrators); + } + + try { + $size = $this->migrationService->estimateExportSize($user, $migrators); + // Convert to MiB and round to one significant digit after the decimal point + $roundedSize = round($size / 1024, 1); + } catch (UserMigrationException $e) { + throw new OCSException($e->getMessage()); + } + + try { + $this->migrationService->checkExportability($user, $migrators); + } catch (NotExportableException $e) { + $warning = $e->getMessage(); + } + + return new DataResponse([ + 'estimatedSize' => $roundedSize, + 'units' => 'MiB', + 'warning' => $warning ?? null, + ], Http::STATUS_OK); + } + + /** + * @NoAdminRequired + * @NoSubAdminRequired + * @PasswordConfirmationRequired + * + * @throws OCSException + */ + public function export(array $migrators): DataResponse { + $user = $this->checkJobAndGetUser(); + $this->checkMigrators($migrators); + try { $this->migrationService->queueExportJob($user, $migrators); } catch (UserMigrationException $e) { @@ -223,24 +273,10 @@ public function export(array $migrators): DataResponse { * @throws OCSException */ public function import(string $path): DataResponse { - $author = $this->userSession->getUser(); + $author = $this->checkJobAndGetUser(); // Set target user to the author as importing into another user's account is not allowed for now $targetUser = $author; - if (empty($author)) { - throw new OCSException('No user currently logged in'); - } - - try { - $job = $this->migrationService->getCurrentJob($targetUser); - } catch (UserMigrationException $e) { - throw new OCSException('Error getting current user migration operation'); - } - - if (!empty($job)) { - throw new OCSException('User migration operation already queued'); - } - try { $this->migrationService->queueImportJob($author, $targetUser, $path); } catch (UserMigrationException $e) { diff --git a/lib/Migrator/FilesMigrator.php b/lib/Migrator/FilesMigrator.php index befe7c47..f1e668e7 100644 --- a/lib/Migrator/FilesMigrator.php +++ b/lib/Migrator/FilesMigrator.php @@ -45,11 +45,12 @@ use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; use OCP\UserMigration\IMigrator; +use OCP\UserMigration\ISizeEstimationMigrator; use OCP\UserMigration\TMigratorBasicVersionHandling; use OCP\UserMigration\UserMigrationException; use Symfony\Component\Console\Output\OutputInterface; -class FilesMigrator implements IMigrator { +class FilesMigrator implements IMigrator, ISizeEstimationMigrator { use TMigratorBasicVersionHandling; protected const PATH_FILES = Application::APP_ID.'/files'; @@ -86,6 +87,46 @@ public function __construct( $this->l10n = $l10n; } + /** + * {@inheritDoc} + */ + public function getEstimatedExportSize(IUser $user): int { + $uid = $user->getUID(); + + $userFolder = $this->root->getUserFolder($uid); + + $size = $userFolder->getSize() / 1024; + + // Export file itself is not exported so we subtract it if existing + try { + $exportFile = $userFolder->get(ExportDestination::EXPORT_FILENAME); + if (!($exportFile instanceof File)) { + throw new \InvalidArgumentException('User export is not a file'); + } + + $size -= $exportFile->getSize() / 1024; + } catch (NotFoundException $e) { + // No size subtraction needed if export file doesn't exist + } + + try { + $versionsFolder = $this->root->get('/'.$uid.'/'.FilesVersionsStorage::VERSIONS_ROOT); + if ($versionsFolder instanceof Folder) { + $size += $versionsFolder->getSize() / 1024; + } + } catch (\Throwable $e) { + // Skip versions folder size estimate on failure + } + + // 1MiB for tags and system tags + $size += 1024; + + // 2MiB for comments + $size += 2048; + + return (int)ceil($size); + } + /** * {@inheritDoc} */ diff --git a/lib/NotExportableException.php b/lib/NotExportableException.php new file mode 100644 index 00000000..a0afba4c --- /dev/null +++ b/lib/NotExportableException.php @@ -0,0 +1,32 @@ + + * + * @author Christopher Ng + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see . + * + */ + +namespace OCA\UserMigration; + +use Exception; + +class NotExportableException extends Exception { +} diff --git a/lib/Service/UserMigrationService.php b/lib/Service/UserMigrationService.php index f80d08d4..e984fa04 100644 --- a/lib/Service/UserMigrationService.php +++ b/lib/Service/UserMigrationService.php @@ -28,15 +28,20 @@ namespace OCA\UserMigration\Service; use OC\AppFramework\Bootstrap\Coordinator; +use OC\Cache\CappedMemoryCache; use OCA\UserMigration\BackgroundJob\UserExportJob; use OCA\UserMigration\BackgroundJob\UserImportJob; use OCA\UserMigration\Db\UserExport; use OCA\UserMigration\Db\UserExportMapper; use OCA\UserMigration\Db\UserImport; use OCA\UserMigration\Db\UserImportMapper; +use OCA\UserMigration\ExportDestination; +use OCA\UserMigration\NotExportableException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\BackgroundJob\IJobList; +use OCP\Files\File; use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; use OCP\IConfig; use OCP\IUser; use OCP\IUserManager; @@ -44,6 +49,7 @@ use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; use OCP\UserMigration\IMigrator; +use OCP\UserMigration\ISizeEstimationMigrator; use OCP\UserMigration\TMigratorBasicVersionHandling; use OCP\UserMigration\UserMigrationException; use Psr\Container\ContainerInterface; @@ -71,6 +77,8 @@ class UserMigrationService { protected IJobList $jobList; + protected CappedMemoryCache $internalCache; + protected const ENTITY_JOB_MAP = [ UserExport::class => UserExportJob::class, UserImport::class => UserImportJob::class, @@ -94,12 +102,84 @@ public function __construct( $this->exportMapper = $exportMapper; $this->importMapper = $importMapper; $this->jobList = $jobList; + $this->internalCache = new CappedMemoryCache(); $this->mandatory = true; } /** * @param ?string[] $filteredMigratorList If not null, only these migrators will run. If empty only the main account data will be exported. + * + * @return int Estimated size in KiB + * + * @throws UserMigrationException + */ + public function estimateExportSize(IUser $user, ?array $filteredMigratorList = null): int { + // 1MiB for base user data + $size = 1024; + + foreach ($this->getMigrators() as $migrator) { + if ($filteredMigratorList !== null && !in_array($migrator->getId(), $filteredMigratorList)) { + continue; + } + $cacheKey = $user->getUID() . '::' . $migrator->getId(); + if ($this->internalCache->hasKey($cacheKey)) { + $size += $this->internalCache->get($cacheKey); + continue; + } + if ($migrator instanceof ISizeEstimationMigrator) { + try { + $migratorSize = $migrator->getEstimatedExportSize($user); + } catch (Throwable $e) { + throw new UserMigrationException('Could not estimate export size for ' . $migrator->getDisplayName(), 0, $e); + } + $this->internalCache->set($cacheKey, $migratorSize); + $size += $migratorSize; + } + } + + return $size; + } + + /** + * @param ?string[] $filteredMigratorList If not null, only these migrators will run. If empty only the main account data will be exported. + * + * @throws NotExportableException + */ + public function checkExportability(IUser $user, ?array $filteredMigratorList = null): void { + try { + $userFolder = $this->root->getUserFolder($user->getUID()); + $freeSpace = (int)ceil($userFolder->getFreeSpace() / 1024); + } catch (Throwable $e) { + throw new NotExportableException('Error calculating amount of free space available'); + } + + try { + $exportFile = $userFolder->get(ExportDestination::EXPORT_FILENAME); + if (!($exportFile instanceof File)) { + throw new \InvalidArgumentException('User export is not a file'); + } + // Add previous export file size to free space as it will be overwritten if existing + $freeSpace += $exportFile->getSize() / 1024; + } catch (NotFoundException $e) { + // No size addition needed if export file doesn't exist + } + + try { + $exportSize = $this->estimateExportSize($user, $filteredMigratorList); + } catch (UserMigrationException $e) { + throw new NotExportableException('Error estimating export size'); + } + + $freeSpaceAfterExport = $freeSpace - $exportSize; + if ($freeSpaceAfterExport < 0) { + throw new NotExportableException('Insufficient storage space available to export, please free up ' . (int)abs($freeSpaceAfterExport) . ' KiB or more to be able to export your data'); + } + } + + /** + * @param ?string[] $filteredMigratorList If not null, only these migrators will run. If empty only the main account data will be exported. + * * @throws UserMigrationException */ public function export(IExportDestination $exportDestination, IUser $user, ?array $filteredMigratorList = null, ?OutputInterface $output = null): void { diff --git a/tests/stubs/stub.phpstub b/tests/stubs/stub.phpstub index e6f787ba..46e37d55 100644 --- a/tests/stubs/stub.phpstub +++ b/tests/stubs/stub.phpstub @@ -116,3 +116,23 @@ namespace OC\Hooks { interface Emitter { } } + +namespace OC\Cache { + + use OCP\ICache; + + class CappedMemoryCache implements ICache, \ArrayAccess { + public function __construct($capacity = 512); + public function hasKey($key): bool {} + public function get($key) {} + public function set($key, $value, $ttl = 0): bool {} + public function remove($key) {} + public function clear($prefix = '') {} + public function offsetExists($offset): bool {} + public function &offsetGet($offset) {} + public function offsetSet($offset, $value): void {} + public function offsetUnset($offset): void {} + public function getData() {} + public static function isAvailable(): bool {} + } +}