Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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],
],
Expand Down
88 changes: 62 additions & 26 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand All @@ -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);
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
43 changes: 42 additions & 1 deletion lib/Migrator/FilesMigrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}
*/
Expand Down
32 changes: 32 additions & 0 deletions lib/NotExportableException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

/**
* @copyright 2022 Christopher Ng <[email protected]>
*
* @author Christopher Ng <[email protected]>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/

namespace OCA\UserMigration;

use Exception;

class NotExportableException extends Exception {
}
80 changes: 80 additions & 0 deletions lib/Service/UserMigrationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,28 @@
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;
use OCP\Security\ISecureRandom;
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;
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions tests/stubs/stub.phpstub
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
}