diff --git a/src/app/DataMigrator/Account.php b/src/app/DataMigrator/Account.php index 06b8fc82..a9678421 100644 --- a/src/app/DataMigrator/Account.php +++ b/src/app/DataMigrator/Account.php @@ -1,55 +1,75 @@ :". + * For proxy authentication use: "^:" + * or "https://service-admin@password:hostname.domain.tld?loginas=user" + * + * @param string $input Account specification */ public function __construct(string $input) { - // Input can be a valid URL or ":" $url = parse_url($input); + // Not valid URI, try the other form of input if ($url === false || !array_key_exists('user', $url)) { list($user, $password) = explode(':', $input, 2); $url = ['user' => $user, 'pass' => $password]; + + if (strpos($user, '^')) { + list($loginas, $url['user']) = explode('^', $user, 2); + $url['query'] = 'loginas=' . urlencode($loginas); + } } if (isset($url['user'])) { $this->username = urldecode($url['user']); } if (isset($url['pass'])) { $this->password = urldecode($url['pass']); } if (isset($url['host'])) { - $this->uri = $input; + $this->uri = preg_replace('/\?.*$/', '', $input); + } + + if (isset($url['query'])) { + parse_str($url['query'], $params); + if (isset($params['loginas'])) { + $this->loginas = urldecode($params['loginas']); + } } - if (strpos($this->username, '@')) { + if (strpos($this->loginas, '@')) { + $this->email = $this->loginas; + } elseif (strpos($this->username, '@')) { $this->email = $this->username; } } } diff --git a/src/app/DataMigrator/EWS.php b/src/app/DataMigrator/EWS.php index de5a22b0..a3fbb903 100644 --- a/src/app/DataMigrator/EWS.php +++ b/src/app/DataMigrator/EWS.php @@ -1,296 +1,299 @@ DAVClient::TYPE_EVENT, EWS\Contact::FOLDER_TYPE => DAVClient::TYPE_CONTACT, EWS\Task::FOLDER_TYPE => DAVClient::TYPE_TASK, ]; /** @var string Output location */ protected $location; /** @var Account Source account */ protected $source; /** @var Account Destination account */ protected $destination; /** @var DAVClient Data importer */ protected $importer; /** * Print progress/debug information */ public function debug($line) { // TODO: When not in console mode we should // not write to stdout, but to log $output = new \Symfony\Component\Console\Output\ConsoleOutput; $output->writeln($line); } /** * Return destination account */ public function getDestination() { return $this->destination; } /** * Return source account */ public function getSource() { return $this->source; } /** * Execute migration for the specified user */ public function migrate(Account $source, Account $destination, array $options = []): void { $this->source = $source; $this->destination = $destination; // We'll store output in storage/ tree $this->location = storage_path('export/') . $source->email; if (!file_exists($this->location)) { mkdir($this->location, 0740, true); } // Autodiscover and authenticate the user - $this->authenticate($source->username, $source->password); + $this->authenticate($source->username, $source->password, $source->loginas); $this->debug("Logged in. Fetching folders hierarchy..."); $folders = $this->getFolders(); if (empty($options['import-only'])) { foreach ($folders as $folder) { $this->debug("Syncing folder {$folder['fullname']}..."); if ($folder['total'] > 0) { $this->syncItems($folder); } } $this->debug("Done."); } if (empty($options['export-only'])) { $this->debug("Importing to Kolab account..."); $this->importer = new DAVClient($destination); // TODO: If we were to stay with this storage solution and need still // the import mode, it should not require connecting again to // Exchange. Now we do this for simplicity. foreach ($folders as $folder) { $this->debug("Syncing folder {$folder['fullname']}..."); $this->importer->createFolder($folder['fullname'], $folder['type']); if ($folder['total'] > 0) { $files = array_diff(scandir($folder['location']), ['.', '..']); foreach ($files as $file) { $this->debug("* Pushing item {$file}..."); $this->importer->createObjectFromFile($folder['location'] . '/' . $file, $folder['fullname']); // TODO: remove the file/folder? } } } - $this->debug("Done."); + $this->debug("Done."); } } /** * Autodiscover the server and authenticate the user */ - protected function authenticate(string $user, string $password): void + protected function authenticate(string $user, string $password, string $loginas = null): void { // You should never run the Autodiscover more than once. // It can make between 1 and 5 calls before giving up, or before finding your server, // depending on how many different attempts it needs to make. $api = API\ExchangeAutodiscover::getAPI($user, $password); $server = $api->getClient()->getServer(); $version = $api->getClient()->getVersion(); + $options = ['version' => $version]; + + if ($loginas) { + $options['impersonation'] = $loginas; + } $this->debug("Connected to $server ($version). Authenticating..."); - $this->api = API::withUsernameAndPassword($server, $user, $password, [ - 'version' => $version - ]); + $this->api = API::withUsernameAndPassword($server, $user, $password, $options); } /** * Get folders hierarchy */ protected function getFolders(): array { // Get full folders hierarchy $options = [ 'Traversal' => 'Deep', ]; $folders = $this->api->getChildrenFolders('root', $options); $result = []; foreach ($folders as $folder) { $class = $folder->getFolderClass(); // Skip folder types we do not support if (!in_array($class, $this->folder_classes)) { continue; } $name = $fullname = $folder->getDisplayName(); $id = $folder->getFolderId()->getId(); $parentId = $folder->getParentFolderId()->getId(); // Create folder name with full path if ($parentId && !empty($result[$parentId])) { $fullname = $result[$parentId]['fullname'] . '/' . $name; } // Top-level folder, check if it's a special folder we should ignore // FIXME: Is there a better way to distinguish user folders from system ones? if (in_array($fullname, $this->folder_exceptions) || strpos($fullname, 'OwaFV15.1All') === 0 ) { continue; } $result[$id] = [ 'id' => $folder->getFolderId(), 'total' => $folder->getTotalCount(), 'class' => $class, 'type' => array_key_exists($class, $this->type_map) ? $this->type_map[$class] : null, 'name' => $name, 'fullname' => $fullname, 'location' => $this->location . '/' . $fullname, ]; } return $result; } /** * Synchronize specified folder */ protected function syncItems(array $folder): void { $request = [ // Exchange's maximum is 1000 'IndexedPageItemView' => ['MaxEntriesReturned' => 100, 'Offset' => 0, 'BasePoint' => 'Beginning'], 'ParentFolderIds' => $folder['id']->toArray(true), 'Traversal' => 'Shallow', 'ItemShape' => [ 'BaseShape' => 'IdOnly', 'AdditionalProperties' => [ 'FieldURI' => ['FieldURI' => 'item:ItemClass'], ], ], ]; $request = Type::buildFromArray($request); // Note: It is not possible to get mimeContent with FindItem request // That's why we first get the list of object identifiers and // then call GetItem on each separately. // TODO: It might be feasible to get all properties for object types // for which we don't use MimeContent, for better performance. // Request first page $response = $this->api->getClient()->FindItem($request); foreach ($response as $item) { $this->syncItem($item, $folder); } // Request other pages until we got all while (!$response->isIncludesLastItemInRange()) { $response = $this->api->getNextPage($response); foreach ($response as $item) { $this->syncItem($item, $folder); } } } /** * Synchronize specified object */ protected function syncItem(Type $item, array $folder): void { if ($driver = EWS\Item::factory($this, $item, $folder)) { $driver->syncItem($item); return; } // TODO IPM.Note (email) and IPM.StickyNote // Note: iTip messages in mail folders may have different class assigned // https://docs.microsoft.com/en-us/office/vba/outlook/Concepts/Forms/item-types-and-message-classes $this->debug("Unsupported object type: {$item->getItemClass()}. Skiped."); } }