diff --git a/src/.gitignore b/src/.gitignore --- a/src/.gitignore +++ b/src/.gitignore @@ -25,5 +25,4 @@ resources/countries.php resources/build/js/ database/seeds/ -src/public/themes/active cache diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php --- a/src/app/Backends/DAV.php +++ b/src/app/Backends/DAV.php @@ -9,6 +9,7 @@ public const TYPE_VEVENT = 'VEVENT'; public const TYPE_VTODO = 'VTODO'; public const TYPE_VCARD = 'VCARD'; + public const TYPE_NOTIFICATION = 'NOTIFICATION'; protected const NAMESPACES = [ self::TYPE_VEVENT => 'urn:ietf:params:xml:ns:caldav', @@ -20,13 +21,14 @@ protected $user; protected $password; protected $responseHeaders = []; + protected $homes; /** * Object constructor */ - public function __construct($user, $password) + public function __construct($user, $password, $url = null) { - $this->url = \config('services.dav.uri'); + $this->url = $url ?: \config('services.dav.uri'); $this->user = $user; $this->password = $password; } @@ -34,23 +36,13 @@ /** * Discover DAV home (root) collection of a specified type. * - * @param string $component Component to filter by (VEVENT, VTODO, VCARD) - * - * @return string|false Home collection location or False on error + * @return array|false Home locations or False on error */ - public function discover(string $component = self::TYPE_VEVENT) + public function discover() { - $roots = [ - self::TYPE_VEVENT => 'calendars', - self::TYPE_VTODO => 'calendars', - self::TYPE_VCARD => 'addressbooks', - ]; - - $homes = [ - self::TYPE_VEVENT => 'calendar-home-set', - self::TYPE_VTODO => 'calendar-home-set', - self::TYPE_VCARD => 'addressbook-home-set', - ]; + if (is_array($this->homes)) { + return $this->homes; + } $path = parse_url($this->url, PHP_URL_PATH); @@ -62,64 +54,88 @@ . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) - $headers = ['Depth' => 1, 'Prefer' => 'return-minimal']; - - $response = $this->request('/' . $roots[$component], 'PROPFIND', $body, $headers); + $response = $this->request('/', 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { - \Log::error("Failed to get current-user-principal for {$component} from the DAV server."); + \Log::error("Failed to get current-user-principal from the DAV server."); return false; } $elements = $response->getElementsByTagName('response'); + $principal_href = ''; foreach ($elements as $element) { - foreach ($element->getElementsByTagName('prop') as $prop) { + foreach ($element->getElementsByTagName('current-user-principal') as $prop) { $principal_href = $prop->nodeValue; break; } } - if (empty($principal_href)) { - \Log::error("No principal on the DAV server."); - return false; - } - - if ($path && strpos($principal_href, $path) === 0) { + if ($path && str_starts_with($principal_href, $path)) { $principal_href = substr($principal_href, strlen($path)); } $body = '' - . '' + . '' . '' - . '' + . '' + . '' + . '' . '' . ''; $response = $this->request($principal_href, 'PROPFIND', $body); if (empty($response)) { - \Log::error("Failed to get homes for {$component} from the DAV server."); + \Log::error("Failed to get home collections from the DAV server."); return false; } - $root_href = false; $elements = $response->getElementsByTagName('response'); + $homes = []; - foreach ($elements as $element) { - foreach ($element->getElementsByTagName('prop') as $prop) { - $root_href = $prop->nodeValue; - break; + if ($element = $response->getElementsByTagName('response')->item(0)) { + if ($prop = $element->getElementsByTagName('prop')->item(0)) { + foreach ($prop->childNodes as $home) { + if ($home->firstChild && $home->firstChild->localName == 'href') { + $href = $home->firstChild->nodeValue; + + if ($path && str_starts_with($href, $path)) { + $href = substr($href, strlen($path)); + } + + $homes[$home->localName] = $href; + } + } } } - if (!empty($root_href)) { - if ($path && strpos($root_href, $path) === 0) { - $root_href = substr($root_href, strlen($path)); - } + return $this->homes = $homes; + } + + /** + * Get user home folder of specified type + * + * @param string $type Home type or component name + * + * @return string|null Folder location href + */ + public function getHome($type) + { + $options = [ + self::TYPE_VEVENT => 'calendar-home-set', + self::TYPE_VTODO => 'calendar-home-set', + self::TYPE_VCARD => 'addressbook-home-set', + self::TYPE_NOTIFICATION => 'notification-URL', + ]; + + $homes = $this->discover(); + + if (is_array($homes) && isset($options[$type])) { + return $homes[$options[$type]] ?? null; } - return $root_href; + return null; } /** @@ -142,9 +158,9 @@ */ public function listFolders(string $component) { - $root_href = $this->discover($component); + $root_href = $this->getHome($component); - if ($root_href === false) { + if ($root_href === null) { return false; } @@ -209,7 +225,8 @@ $response = $this->request($object->href, 'PUT', $object, $headers); if ($response !== false) { - if ($etag = $this->responseHeaders['etag']) { + if (!empty($this->responseHeaders['ETag'])) { + $etag = $this->responseHeaders['ETag'][0]; if (preg_match('|^".*"$|', $etag)) { $etag = substr($etag, 1, -1); } @@ -314,6 +331,22 @@ return $response !== false; } + /** + * Check server options (and authentication) + * + * @return false|array DAV capabilities on success, False on error + */ + public function options() + { + $response = $this->request('', 'OPTIONS'); + + if ($response !== false) { + return preg_split('/,\s+/', implode(',', $this->responseHeaders['DAV'] ?? [])); + } + + return false; + } + /** * Search DAV objects in a folder. * @@ -430,7 +463,7 @@ { $doc = new \DOMDocument('1.0', 'UTF-8'); - if (stripos($xml, 'loadXML($xml)) { throw new \Exception("Failed to parse XML"); } @@ -456,7 +489,11 @@ $head .= "{$header_name}: {$header_value}\n"; } - if (stripos($body, 'formatOutput = true; @@ -469,7 +506,7 @@ $body = $doc->saveXML(); } - return $head . "\n" . rtrim($body); + return $head . (is_string($body) && strlen($body) > 0 ? "\n{$body}" : ''); } /** @@ -504,7 +541,7 @@ $this->responseHeaders = []; - if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) { + if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && str_starts_with($path, $rootPath)) { $path = substr($path, strlen($rootPath)); } @@ -526,7 +563,8 @@ } if ($debug) { - \Log::debug("C: {$method}: {$url}\n" . $this->debugBody($body, $headers)); + $body = $this->debugBody($body, $headers); + \Log::debug("C: {$method}: {$url}" . (strlen($body) > 0 ? "\n$body" : '')); } $response = $client->send($method, $url); diff --git a/src/app/Backends/DAV/Opaque.php b/src/app/Backends/DAV/Opaque.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/DAV/Opaque.php @@ -0,0 +1,23 @@ +content = file_get_contents($filename); + } + + /** + * Create string representation of the DAV object + * + * @return string + */ + public function __toString() + { + return $this->content; + } +} diff --git a/src/app/Console/Commands/Data/MigrateCommand.php b/src/app/Console/Commands/Data/MigrateCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Data/MigrateCommand.php @@ -0,0 +1,60 @@ +argument('src')); + $dst = new DataMigrator\Account($this->argument('dst')); + $options = [ + 'type' => $this->option('type'), + 'force' => $this->option('force'), + 'stdout' => true, + ]; + + $migrator = new DataMigrator\Engine(); + $migrator->migrate($src, $dst, $options); + } +} diff --git a/src/app/DataMigrator/Account.php b/src/app/DataMigrator/Account.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/Account.php @@ -0,0 +1,100 @@ +:". + * For proxy authentication use: "**" as username. + * + * @param string $input Account specification + */ + public function __construct(string $input) + { + $url = parse_url($input); + + // Not valid URI, try the other form of input + if ($url === false || !array_key_exists('scheme', $url)) { + list($user, $password) = explode(':', $input, 2); + $url = ['user' => $user, 'pass' => $password]; + } + + if (isset($url['user'])) { + $this->username = urldecode($url['user']); + + if (strpos($this->username, '**')) { + list($this->username, $this->loginas) = explode('**', $this->username, 2); + } + } + + if (isset($url['pass'])) { + $this->password = urldecode($url['pass']); + } + + if (isset($url['scheme'])) { + $this->scheme = strtolower($url['scheme']); + } + + if (isset($url['host'])) { + $this->host = $url['host']; + $this->uri = $this->scheme . '://' . $url['host'] . ($url['path'] ?? ''); + } + + if (!empty($url['query'])) { + parse_str($url['query'], $this->params); + } + + if (strpos($this->loginas, '@')) { + $this->email = $this->loginas; + } elseif (strpos($this->username, '@')) { + $this->email = $this->username; + } + + $this->input = $input; + } + + /** + * Returns string representation of the object. + * You can use the result as an input to the object constructor. + * + * @return string Account string representation + */ + public function __toString(): string + { + return $this->input; + } +} diff --git a/src/app/DataMigrator/DAV.php b/src/app/DataMigrator/DAV.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/DAV.php @@ -0,0 +1,173 @@ +username . ($account->loginas ? "**{$account->loginas}" : ''); + $baseUri = rtrim($account->uri, '/'); + $baseUri = preg_replace('|^dav|', 'http', $baseUri); + + $this->settings = [ + 'baseUri' => $baseUri, + 'userName' => $username, + 'password' => $account->password, + ]; + + $this->client = new DAVClient($username, $account->password, $baseUri); + $this->engine = $engine; + $this->account = $account; + } + + /** + * Check user credentials. + * + * @throws \Exception + */ + public function authenticate() + { + try { + $result = $this->client->options(); + } catch (\Exception $e) { + throw new \Exception("Invalid DAV credentials or server."); + } + } + + /** + * Create an item in a folder. + * + * @param string $filename File location + * @param Folder $folder Folder + * + * @throws \Exception + */ + public function createItemFromFile(string $filename, Folder $folder): void + { + $href = $this->getFolderPath($folder) . '/' . pathinfo($filename, PATHINFO_BASENAME); + + $object = new DAVOpaque($filename); + $object->href = $href; + + switch ($folder->type) { + case Engine::TYPE_EVENT: + case Engine::TYPE_TASK: + $object->contentType = 'text/calendar; charset=utf-8'; + break; + + case Engine::TYPE_CONTACT: + $object->contentType = 'text/vcard; charset=utf-8'; + break; + } + + if ($this->client->create($object) === false) { + throw new \Exception("Failed to save object into DAV server at {$href}"); + } + } + + /** + * Create a folder. + * + * @param Folder $folder Folder data + * + * @throws \Exception on error + */ + public function createFolder(Folder $folder): void + { + $dav_type = $this->type2DAV($folder->type); + $folders = $this->client->listFolders($dav_type); + + if ($folders === false) { + throw new \Exception("Failed to list folders on the DAV server"); + } + + // Note: iRony flattens the list by modifying the folder name + // This is not going to work with Cyrus DAV, but anyway folder + // hierarchies support is not full in Kolab 4. + foreach ($folders as $dav_folder) { + if (str_replace(' » ', '/', $dav_folder->name) === $folder->fullname) { + // do nothing, folder already exists + return; + } + } + + $home = $this->client->getHome($dav_type); + $folder_id = Utils::uuidStr(); + $collection_type = $dav_type == DAVClient::TYPE_VCARD ? 'addressbook' : 'calendar'; + + // We create all folders on the top-level + $dav_folder = new DAVFolder(); + $dav_folder->name = $folder->fullname; + $dav_folder->href = rtrim($home, '/') . '/' . $folder_id; + $dav_folder->components = [$dav_type]; + $dav_folder->types = ['collection', $collection_type]; + + if ($this->client->folderCreate($dav_folder) === false) { + throw new \Exception("Failed to create a DAV folder {$dav_folder->href}"); + } + } + + /** + * Get folder relative URI + */ + protected function getFolderPath(Folder $folder): string + { + $folders = $this->client->listFolders($this->type2DAV($folder->type)); + + if ($folders === false) { + throw new \Exception("Failed to list folders on the DAV server"); + } + + // Note: iRony flattens the list by modifying the folder name + // This is not going to work with Cyrus DAV, but anyway folder + // hierarchies support is not full in Kolab 4. + foreach ($folders as $dav_folder) { + if (str_replace(' » ', '/', $dav_folder->name) === $folder->fullname) { + return rtrim($dav_folder->href, '/'); + } + } + + throw new \Exception("Folder not found: {$folder->fullname}"); + } + + /** + * Map Kolab type into DAV object type + */ + protected static function type2DAV(string $type): string + { + switch ($type) { + case Engine::TYPE_EVENT: + return DAVClient::TYPE_VEVENT; + case Engine::TYPE_TASK: + return DAVClient::TYPE_VTODO; + case Engine::TYPE_CONTACT: + case Engine::TYPE_GROUP: + return DAVClient::TYPE_VCARD; + default: + throw new \Exception("Cannot map type '{$type}' to DAV"); + } + } +} diff --git a/src/app/DataMigrator/EWS.php b/src/app/DataMigrator/EWS.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/EWS.php @@ -0,0 +1,422 @@ + Engine::TYPE_EVENT, + EWS\Contact::FOLDER_TYPE => Engine::TYPE_CONTACT, + EWS\Task::FOLDER_TYPE => Engine::TYPE_TASK, + ]; + + /** @var Account Account to operate on */ + protected $account; + + /** @var Engine Data migrator engine */ + protected $engine; + + + /** + * Object constructor + */ + public function __construct(Account $account, Engine $engine) + { + $this->account = $account; + $this->engine = $engine; + } + + /** + * Server autodiscovery + */ + public static function autodiscover(string $user, string $password): ?string + { + // 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. + + // TODO: Autodiscovery may fail with an exception thrown. Handle this nicely. + // TODO: Looks like this autodiscovery also does not work w/Basic Auth? + + $api = API\ExchangeAutodiscover::getAPI($user, $password); + + $server = $api->getClient()->getServer(); + $version = $api->getClient()->getVersion(); + + return sprintf('ews://%s:%s@%s', urlencode($user), urlencode($password), $server); + } + + /** + * Authenticate to EWS (initialize the EWS client) + */ + public function authenticate() + { + if (!empty($this->account->params['client_id'])) { + $this->api = $this->authenticateWithOAuth2( + $this->account->host, + $this->account->username, + $this->account->params['client_id'], + $this->account->params['client_secret'], + $this->account->params['tenant_id'] + ); + } else { + // Note: This initializes the client, but not yet connects to the server + // TODO: To know that the credentials work we'll have to do some API call. + $this->api = $this->authenticateWithPassword( + $this->account->host, + $this->account->username, + $this->account->password, + $this->account->loginas + ); + } + } + + /** + * Autodiscover the server and authenticate the user + */ + protected function authenticateWithPassword(string $server, string $user, string $password, string $loginas = null) + { + // Note: Since 2023-01-01 EWS at Office365 requires OAuth2, no way back to basic auth. + + \Log::debug("[EWS] Using basic authentication on $server..."); + + $options = []; + + if ($loginas) { + $options['impersonation'] = $loginas; + } + + $this->engine->setOption('ews', [ + 'options' => $options, + 'server' => $server, + ]); + + return API::withUsernameAndPassword($server, $user, $password, $this->apiOptions($options)); + } + + /** + * Authenticate with a token (Office365) + */ + protected function authenticateWithToken(string $server, string $user, string $token, $expires_at = null) + { + \Log::debug("[EWS] Using token authentication on $server..."); + + $options = ['impersonation' => $user]; + + $this->engine->setOption('ews', [ + 'options' => $options, + 'server' => $server, + 'token' => $token, + 'expires_at' => $expires_at, + ]); + + return API::withCallbackToken($server, $token, $this->apiOptions($options)); + } + + /** + * Authenticate with OAuth2 (Office365) - get the token + */ + protected function authenticateWithOAuth2(string $server, + string $user, string $client_id, string $client_secret, string $tenant_id) + { + // See https://github.com/Garethp/php-ews/blob/master/examples/basic/authenticatingWithOAuth.php + // See https://github.com/Garethp/php-ews/issues/236#issuecomment-1292521527 + // To register OAuth2 app goto https://entra.microsoft.com > Applications > App registrations + + \Log::debug("[EWS] Fetching OAuth2 token from $server..."); + + $scope = 'https://outlook.office365.com/.default'; + $token_uri = "https://login.microsoftonline.com/{$tenant_id}/oauth2/v2.0/token"; + // $authUri = "https://login.microsoftonline.com/{$tenant_id}/oauth2/authorize"; + + $response = Http::asForm() + ->timeout(5) + ->post($token_uri, [ + 'client_id' => $client_id, + 'client_secret' => $client_secret, + 'scope' => $scope, + 'grant_type' => 'client_credentials', + ]) + ->throwUnlessStatus(200); + + $token = $response->json('access_token'); + + // Note: Office365 default token expiration time is ~1h, + $expires_in = $response->json('expires_in'); + $expires_at = now()->addSeconds($expires_in)->toDateTimeString(); + + return $this->authenticateWithToken($server, $user, $token, $expires_at); + } + + /** + * Get folders hierarchy + */ + public function getFolders($types = []): array + { + // Get full folders hierarchy + $options = [ + 'Traversal' => 'Deep', + ]; + + $folders = $this->api->getChildrenFolders('root', $options); + + $result = []; + + foreach ($folders as $folder) { + $class = $folder->getFolderClass(); + $type = $this->type_map[$class] ?? null; + + // Skip folder types we do not support (need) + if (empty($type) || (!empty($types) && !in_array($type, $types))) { + continue; + } + + // Note: Folder names are localized + $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] = Folder::fromArray([ + 'id' => $folder->getFolderId()->toArray(true), + 'total' => $folder->getTotalCount(), + 'class' => $class, + 'type' => $this->type_map[$class] ?? null, + 'name' => $name, + 'fullname' => $fullname, + ]); + } + + return $result; + } + + /** + * Fetch a list of folder items + */ + public function fetchItemList(Folder $folder, $callback): void + { + // Job processing - initialize environment + $this->initEnv($this->engine->queue); + + // The folder is empty, we can stop here + if (empty($folder->total)) { + return; + } + + $request = [ + // Exchange's maximum is 1000 + 'IndexedPageItemView' => ['MaxEntriesReturned' => 100, 'Offset' => 0, 'BasePoint' => 'Beginning'], + 'ParentFolderIds' => $folder->id, + '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); + + // @phpstan-ignore-next-line + foreach ($response as $item) { + if ($item = $this->toItem($item, $folder)) { + $callback($item); + } + } + + // Request other pages until we got all + while (!$response->isIncludesLastItemInRange()) { + // @phpstan-ignore-next-line + $response = $this->api->getNextPage($response); + + foreach ($response as $item) { + if ($item = $this->toItem($item, $folder)) { + $callback($item); + } + } + } + } + + /** + * Fetching an item + */ + public function fetchItem(Item $item): string + { + // Job processing - initialize environment + $this->initEnv($this->engine->queue); + + if ($driver = EWS\Item::factory($this, $item)) { + return $driver->fetchItem($item); + } + + throw new \Exception("Failed to fetch an item from EWS"); + } + + /** + * Get the source account + */ + public function getSourceAccount(): Account + { + return $this->engine->source; + } + + /** + * Get the destination account + */ + public function getDestinationAccount(): Account + { + return $this->engine->destination; + } + + /** + * Synchronize specified object + */ + protected function toItem(Type $item, Folder $folder): ?Item + { + $item = Item::fromArray([ + 'id' => $item->getItemId()->toArray(), + 'class' => $item->getItemClass(), + 'folder' => $folder, + ]); + + // TODO: We don't need to instantiate Item at this point, instead + // implement EWS\Item::validateClass() method + if ($driver = EWS\Item::factory($this, $item)) { + return $item; + } + + return null; + } + + /** + * Set common API options + */ + protected function apiOptions(array $options): array + { + if (empty($options['version'])) { + $options['version'] = API\ExchangeWebServices::VERSION_2013; + } + + // If you want to inject your own GuzzleClient for the requests + // $options['httpClient]' = $client; + + // In debug mode record all responses + /* + if (\config('app.debug')) { + $options['httpPlayback'] = [ + 'mode' => 'record', + 'recordLocation' => \storage_path('ews'), + ]; + } + */ + + return $options; + } + + /** + * Initialize environment for job execution + * + * @param Queue $queue Queue + */ + protected function initEnv(Queue $queue): void + { + $ews = $queue->data['options']['ews']; + + if (!empty($ews['token'])) { + // TODO: Refresh the token if needed + $this->api = API::withCallbackToken( + $ews['server'], + $ews['token'], + $this->apiOptions($ews['options']) + ); + } else { + $this->api = API::withUsernameAndPassword( + $ews['server'], + $this->account->username, + $this->account->password, + $this->apiOptions($ews['options']) + ); + } + } +} diff --git a/src/app/DataMigrator/EWS/Appointment.php b/src/app/DataMigrator/EWS/Appointment.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/EWS/Appointment.php @@ -0,0 +1,100 @@ + 'calendar:UID']; + + return $request; + } + + /** + * Process event object + */ + protected function processItem(Type $item) + { + // Decode MIME content + $ical = base64_decode((string) $item->getMimeContent()); + + $itemId = implode("\r\n ", str_split($this->itemId, 75 - strlen('X-MS-ID:'))); + + $ical = str_replace("\r\nBEGIN:VEVENT\r\n", "\r\nBEGIN:VEVENT\r\nX-MS-ID:{$itemId}\r\n", $ical); + + // TODO: replace source email with destination email address in ORGANIZER/ATTENDEE + + // Inject attachment bodies into the iCalendar content + // Calendar event attachments are exported as: + // ATTACH:CID:81490FBA13A3DC2BF071B894C96B44BA51BEAAED@eurprd05.prod.outlook.com + if ($item->getHasAttachments()) { + // FIXME: I've tried hard and no matter what ContentId property is always empty + // This means we can't match the CID from iCalendar with the attachment. + // That's why we'll just remove all ATTACH:CID:... occurrences + // and inject attachments to the main event + $ical = preg_replace('/\r\nATTACH:CID:[^\r]+\r\n(\r\n [^\r\n]*)?/', '', $ical); + + foreach ((array) $item->getAttachments()->getFileAttachment() as $attachment) { + $_attachment = $this->getAttachment($attachment); + + $ctype = $_attachment->getContentType(); + $body = $_attachment->getContent(); + + // It looks like Exchange may have an issue with plain text files. + // We'll skip empty files + if (!strlen($body)) { + continue; + } + + // FIXME: This is imo inconsistence on php-ews side that MimeContent + // is base64 encoded, but Content isn't + // TODO: We should not do it in memory to not exceed the memory limit + $body = base64_encode($body); + $body = rtrim(chunk_split($body, 74, "\r\n "), ' '); + + // Inject the attachment at the end of the first VEVENT block + // TODO: We should not do it in memory to not exceed the memory limit + $append = "ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE={$ctype}:\r\n {$body}"; + $pos = strpos($ical, "\r\nEND:VEVENT"); + $ical = substr_replace($ical, $append, $pos + 2, 0); + } + } + + return $ical; + } + + /** + * Get Item UID (Generate a new one if needed) + */ + protected function getUID(Type $item): string + { + if ($this->uid === null) { + // Only appointments have UID property + $this->uid = $item->getUID(); + } + + return $this->uid; + } +} diff --git a/src/app/DataMigrator/EWS/Contact.php b/src/app/DataMigrator/EWS/Contact.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/EWS/Contact.php @@ -0,0 +1,51 @@ +getMimeContent()); + + // Remove empty properties that EWS is exporting + $vcard = preg_replace('|\n[^:]+:;*\r|', '', $vcard); + + // Inject UID (and Exchange item ID) to the vCard + $uid = $this->getUID($item); + $itemId = implode("\r\n ", str_split($this->itemId, 75 - strlen('X-MS-ID:'))); + + $vcard = str_replace("BEGIN:VCARD", "BEGIN:VCARD\r\nUID:{$uid}\r\nX-MS-ID:{$itemId}", $vcard); + + // Note: Looks like PHOTO property is exported properly, so we + // don't have to handle attachments as we do for calendar items + + return $vcard; + } +} diff --git a/src/app/DataMigrator/EWS/DistList.php b/src/app/DataMigrator/EWS/DistList.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/EWS/DistList.php @@ -0,0 +1,61 @@ + [$this->getUID($item)], + 'KIND' => ['group'], + 'FN' => [$item->getDisplayName()], + 'REV' => [$item->getLastModifiedTime(), ['VALUE' => 'DATE-TIME']], + 'X-MS-ID' => [$this->itemId], + ]; + + $vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nPRODID:Kolab EWS Data Migrator\r\n"; + + foreach ($data as $key => $prop) { + $vcard .= $this->formatProp($key, $prop[0], isset($prop[1]) ? $prop[1] : []); + } + + // Process list members + // Note: The fact that getMembers() returns stdClass is probably a bug in php-ews + foreach ($item->getMembers()->Member as $member) { + $mailbox = $member->getMailbox(); + $mailto = $mailbox->getEmailAddress(); + $name = $mailbox->getName(); + + // FIXME: Investigate if mailto: members are handled properly by Kolab + // or we need to use MEMBER:urn:uuid:9bd97510-9dbb-4810-a144-6180962df5e0 syntax + // But do not forget lists can have members that are not contacts + + if ($mailto) { + if ($name && $name != $mailto) { + $mailto = urlencode(sprintf('"%s" <%s>', addcslashes($name, '"'), $mailto)); + } + + $vcard .= $this->formatProp('MEMBER', "mailto:{$mailto}"); + } + } + + $vcard .= "END:VCARD\r\n"; + + return $vcard; + } +} diff --git a/src/app/DataMigrator/EWS/Item.php b/src/app/DataMigrator/EWS/Item.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/EWS/Item.php @@ -0,0 +1,172 @@ +driver = $driver; + $this->folder = $folder; + } + + /** + * Factory method. + * Returns object suitable to handle specified item type. + */ + public static function factory(EWS $driver, ItemInterface $item) + { + $item_class = str_replace('IPM.', '', $item->class); + $item_class = "\App\DataMigrator\EWS\\{$item_class}"; + + if (class_exists($item_class)) { + return new $item_class($driver, $item->folder); + } + } + + /** + * Fetch the specified object and put into a file + */ + public function fetchItem(ItemInterface $item) + { + $itemId = $item->id; + + // Fetch the item + $item = $this->driver->api->getItem($itemId, $this->getItemRequest()); + + $this->itemId = implode('!', $itemId); + + $uid = $this->getUID($item); + + \Log::debug("[EWS] Saving item {$uid}..."); + + // Apply type-specific format converters + $content = $this->processItem($item); + + if (!is_string($content)) { + return; + } + + $uid = preg_replace('/[^a-zA-Z0-9_:@-]/', '', $uid); + + $location = $this->folder->location; + + if (!file_exists($location)) { + mkdir($location, 0740, true); + } + + $location .= '/' . $uid . '.' . $this->fileExtension(); + + file_put_contents($location, $content); + + return $location; + } + + /** + * Item conversion code + */ + abstract protected function processItem(Type $item); + + /** + * Get GetItem request parameters + */ + protected function getItemRequest(): array + { + $request = [ + 'ItemShape' => [ + // Reqest default set of properties + 'BaseShape' => 'Default', + // Additional properties, e.g. LastModifiedTime + // FIXME: How to add multiple properties here? + 'AdditionalProperties' => [ + 'FieldURI' => ['FieldURI' => 'item:LastModifiedTime'], + ] + ] + ]; + + return $request; + } + + /** + * Fetch attachment object from Exchange + */ + protected function getAttachment(Type\FileAttachmentType $attachment) + { + $request = [ + 'AttachmentIds' => [ + $attachment->getAttachmentId()->toXmlObject() + ], + 'AttachmentShape' => [ + 'IncludeMimeContent' => true, + ] + ]; + + return $this->driver->api->getClient()->GetAttachment($request); + } + + /** + * Get Item UID (Generate a new one if needed) + */ + protected function getUID(Type $item): string + { + if ($this->uid === null) { + // We should generate an UID for objects that do not have it + // and inject it into the output file + // FIXME: Should we use e.g. md5($itemId->getId()) instead? + $this->uid = \App\Utils::uuidStr(); + } + + return $this->uid; + } + + /** + * Filename extension for cached file in-processing + */ + protected function fileExtension(): string + { + return constant(static::class . '::FILE_EXT') ?: 'txt'; + } + + /** + * VCard/iCal property formatting + */ + protected function formatProp($name, $value, array $params = []): string + { + $cal = new \Sabre\VObject\Component\VCalendar(); + $prop = new \Sabre\VObject\Property\Text($cal, $name, $value, $params); + + $value = $prop->serialize(); + + // Revert escaping for some props + if ($name == 'RRULE') { + $value = str_replace("\\", '', $value); + } + + return $value; + } +} diff --git a/src/app/DataMigrator/EWS/Note.php b/src/app/DataMigrator/EWS/Note.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/EWS/Note.php @@ -0,0 +1,41 @@ +getMimeContent()); + + return $email; + } +} diff --git a/src/app/DataMigrator/EWS/Task.php b/src/app/DataMigrator/EWS/Task.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/EWS/Task.php @@ -0,0 +1,305 @@ + [$this->getUID($item)], + 'DTSTAMP' => [$this->formatDate($item->getLastModifiedTime()), ['VALUE' => 'DATE-TIME']], + 'CREATED' => [$this->formatDate($item->getDateTimeCreated()), ['VALUE' => 'DATE-TIME']], + 'SEQUENCE' => [intval($item->getChangeCount())], + 'SUMMARY' => [$item->getSubject()], + 'DESCRIPTION' => [(string) $item->getBody()], + 'PERCENT-COMPLETE' => [intval($item->getPercentComplete())], + 'STATUS' => [strtoupper($item->getStatus())], + 'X-MS-ID' => [$this->itemId], + ]; + + if ($dueDate = $item->getDueDate()) { + $data['DUE'] = [$this->formatDate($dueDate), ['VALUE' => 'DATE-TIME']]; + } + + if ($startDate = $item->getStartDate()) { + $data['DTSTART'] = [$this->formatDate($startDate), ['VALUE' => 'DATE-TIME']]; + } + + if (($categories = $item->getCategories()) && $categories->String) { + $data['CATEGORIES'] = [$categories->String]; + } + + if ($sensitivity = $item->getSensitivity()) { + $sensitivity_map = [ + 'CONFIDENTIAL' => 'CONFIDENTIAL', + 'NORMAL' => 'PUBLIC', + 'PERSONAL' => 'PUBLIC', + 'PRIVATE' => 'PRIVATE', + ]; + + $data['CLASS'] = [$sensitivity_map[strtoupper($sensitivity)] ?? 'PUBLIC']; + } + + if ($importance = $item->getImportance()) { + $importance_map = [ + 'HIGH' => '9', + 'NORMAL' => '5', + 'LOW' => '1', + ]; + + $data['PRIORITY'] = [$importance_map[strtoupper($importance)] ?? '0']; + } + + $this->setTaskOrganizer($data, $item); + $this->setTaskRecurrence($data, $item); + + $ical = "BEGIN:VCALENDAR\r\nMETHOD:PUBLISH\r\nVERSION:2.0\r\nPRODID:Kolab EWS Data Migrator\r\nBEGIN:VTODO\r\n"; + + foreach ($data as $key => $prop) { + $ical .= $this->formatProp($key, $prop[0], isset($prop[1]) ? $prop[1] : []); + } + + // Attachments + if ($item->getHasAttachments()) { + foreach ((array) $item->getAttachments()->getFileAttachment() as $attachment) { + $_attachment = $this->getAttachment($attachment); + + $ctype = $_attachment->getContentType(); + $body = $_attachment->getContent(); + + // It looks like Exchange may have an issue with plain text files. + // We'll skip empty files + if (!strlen($body)) { + continue; + } + + // FIXME: This is imo inconsistence on php-ews side that MimeContent + // is base64 encoded, but Content isn't + // TODO: We should not do it in memory to not exceed the memory limit + $body = base64_encode($body); + $body = rtrim(chunk_split($body, 74, "\r\n "), ' '); + + // Inject the attachment at the end of the VTODO block + // TODO: We should not do it in memory to not exceed the memory limit + $ical .= "ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE={$ctype}:\r\n {$body}"; + } + } + + $ical .= $this->getVAlarm($item); + $ical .= "END:VTODO\r\n"; + $ical .= "END:VCALENDAR\r\n"; + + return $ical; + } + + /** + * Set task organizer/attendee + */ + protected function setTaskOrganizer(array &$data, Type $task) + { + // FIXME: Looks like the owner might be an email address or just a full user name + $owner = $task->getOwner(); + $source = $this->driver->getSourceAccount(); + $destination = $this->driver->getDestinationAccount(); + + if (strpos($owner, '@') && $owner != $source->email) { + // Task owned by another person + $data['ORGANIZER'] = ["mailto:{$owner}"]; + + // FIXME: Because attendees are not specified in EWS, assume the user is an attendee + if ($destination->email) { + $params = ['ROLE' => 'REQ-PARTICIPANT', 'CUTYPE' => 'INDIVIDUAL']; + $data['ATTENDEE'] = ["mailto:{$destination->email}", $params]; + } + + return; + } + + // Otherwise it must be owned by the user + if ($destination->email) { + $data['ORGANIZER'] = ["mailto:{$destination->email}"]; + } + } + + /** + * Set task recurrence rule + */ + protected function setTaskRecurrence(array &$data, Type $task) + { + if (empty($task->getIsRecurring()) || empty($task->getRecurrence())) { + return; + } + + $r = $task->getRecurrence(); + $rrule = []; + + if ($recurrence = $r->getDailyRecurrence()) { + $rrule['FREQ'] = 'DAILY'; + $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1; + } elseif ($recurrence = $r->getWeeklyRecurrence()) { + $rrule['FREQ'] = 'WEEKLY'; + $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1; + $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek()); + $rrule['WKST'] = $this->mapDays($recurrence->getFirstDayOfWeek()); + } elseif ($recurrence = $r->getAbsoluteMonthlyRecurrence()) { + $rrule['FREQ'] = 'MONTHLY'; + $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1; + $rrule['BYMONTHDAY'] = $recurrence->getDayOfMonth(); + } elseif ($recurrence = $r->getRelativeMonthlyRecurrence()) { + $rrule['FREQ'] = 'MONTHLY'; + $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1; + $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek(), $recurrence->getDayOfWeekIndex()); + } elseif ($recurrence = $r->getAbsoluteYearlyRecurrence()) { + $rrule['FREQ'] = 'YEARLY'; + $rrule['BYMONTH'] = $this->mapMonths($recurrence->getMonth()); + $rrule['BYMONTHDAY'] = $recurrence->getDayOfMonth(); + } elseif ($recurrence = $r->getRelativeYearlyRecurrence()) { + $rrule['FREQ'] = 'YEARLY'; + $rrule['BYMONTH'] = $this->mapMonths($recurrence->getMonth()); + $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek(), $recurrence->getDayOfWeekIndex()); + } else { + // There might be *Regeneration rules that we don't support + \Log::debug("[EWS] Unsupported Recurrence property value. Ignored."); + } + + if (!empty($rrule)) { + if ($recurrence = $r->getNumberedRecurrence()) { + $rrule['COUNT'] = $recurrence->getNumberOfOccurrences(); + } elseif ($recurrence = $r->getEndDateRecurrence()) { + $rrule['UNTIL'] = $this->formatDate($recurrence->getEndDate()); + } + + $rrule = array_filter($rrule); + $rrule = trim(array_reduce( + array_keys($rrule), + function ($carry, $key) use ($rrule) { + return $carry . ';' . $key . '=' . $rrule[$key]; + } + ), ';'); + + $data['RRULE'] = [$rrule]; + } + } + + /** + * Get VALARM block for the task Reminder + */ + protected function getVAlarm(Type $task): string + { + // FIXME: To me it looks like ReminderMinutesBeforeStart property is not used + + $date = $this->formatDate($task->getReminderDueBy()); + + if (empty($task->getReminderIsSet()) || empty($date)) { + return ''; + } + + return "BEGIN:VALARM\r\nACTION:DISPLAY\r\n" + . "TRIGGER;VALUE=DATE-TIME:{$date}\r\n" + . "END:VALARM\r\n"; + } + + /** + * Convert EWS representation of recurrence days to iCal + */ + protected function mapDays(string $days, string $index = ''): string + { + if (preg_match('/(Day|Weekday|WeekendDay)/', $days)) { + // not supported + return ''; + } + + $days_map = [ + 'Sunday' => 'SU', + 'Monday' => 'MO', + 'Tuesday' => 'TU', + 'Wednesday' => 'WE', + 'Thursday' => 'TH', + 'Friday' => 'FR', + 'Saturday' => 'SA', + ]; + + $index_map = [ + 'First' => 1, + 'Second' => 2, + 'Third' => 3, + 'Fourth' => 4, + 'Last' => -1, + ]; + + $days = explode(' ', $days); + $days = array_map( + function ($day) use ($days_map, $index_map, $index) { + return ($index ? $index_map[$index] : '') . $days_map[$day]; + }, + $days + ); + + return implode(',', $days); + } + + /** + * Convert EWS representation of recurrence month to iCal + */ + protected function mapMonths(string $months): string + { + $months_map = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + + $months = explode(' ', $months); + $months = array_map( + function ($month) use ($months_map) { + return array_search($month, $months_map) + 1; + }, + $months + ); + + return implode(',', $months); + } + + /** + * Format EWS date-time into a iCalendar date-time + */ + protected function formatDate($datetime) + { + if (empty($datetime)) { + return null; + } + + return str_replace(['Z', '-', ':'], '', $datetime); + } +} diff --git a/src/app/DataMigrator/Engine.php b/src/app/DataMigrator/Engine.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/Engine.php @@ -0,0 +1,273 @@ +source = $source; + $this->destination = $destination; + $this->options = $options; + + // Create a unique identifier for the migration request + $queue_id = md5(strval($source) . strval($destination) . $options['type']); + + // If queue exists, we'll display the progress only + if ($queue = Queue::find($queue_id)) { + // If queue contains no jobs, assume invalid + // TODO: An better API to manage (reset) queues + if (!$queue->jobs_started || !empty($options['force'])) { + $queue->delete(); + } else { + while (true) { + $this->debug(sprintf("Progress [%d of %d]\n", $queue->jobs_finished, $queue->jobs_started)); + + if ($queue->jobs_started == $queue->jobs_finished) { + break; + } + + sleep(1); + $queue->refresh(); + } + + return; + } + } + + // Initialize the source + $this->exporter = $this->initDriver($source, ExporterInterface::class); + $this->exporter->authenticate(); + + // Initialize the destination + $this->importer = $this->initDriver($destination, ImporterInterface::class); + $this->importer->authenticate(); + + // $this->debug("Source/destination user credentials verified."); + $this->debug("Fetching folders hierarchy..."); + + // Create a queue + $this->createQueue($queue_id); + + // We'll store output in storage/ tree + $location = storage_path('export/') . $source->email; + + if (!file_exists($location)) { + mkdir($location, 0740, true); + } + + $types = preg_split('/\s*,\s*/', strtolower($this->options['type'] ?? '')); + + $folders = $this->exporter->getFolders($types); + $count = 0; + + foreach ($folders as $folder) { + $this->debug("Processing folder {$folder->fullname}..."); + + $folder->queueId = $queue_id; + $folder->location = $location; + + // Dispatch the job (for async execution) + Jobs\FolderJob::dispatch($folder); + $count++; + } + + $this->queue->bumpJobsStarted($count); + + $this->debug(sprintf('Done. %d %s created in queue: %s.', $count, Str::plural('job', $count), $queue_id)); + } + + /** + * Processing of a folder synchronization + */ + public function processFolder(Folder $folder): void + { + // Job processing - initialize environment + $this->envFromQueue($folder->queueId); + + // Create the folder on the destination server + $this->importer->createFolder($folder); + + $count = 0; + + // Fetch items from the source + $this->exporter->fetchItemList( + $folder, + function (Item $item) use (&$count) { + // Dispatch the job (for async execution) + Jobs\ItemJob::dispatch($item); + $count++; + } + ); + + if ($count) { + $this->queue->bumpJobsStarted($count); + } + + $this->queue->bumpJobsFinished(); + } + + /** + * Processing of item synchronization + */ + public function processItem(Item $item): void + { + // Job processing - initialize environment + $this->envFromQueue($item->folder->queueId); + + if ($filename = $this->exporter->fetchItem($item)) { + $this->importer->createItemFromFile($filename, $item->folder); + // TODO: remove the file + } + + $this->queue->bumpJobsFinished(); + } + + /** + * Print progress/debug information + */ + public function debug($line) + { + if (!empty($this->options['stdout'])) { + $output = new \Symfony\Component\Console\Output\ConsoleOutput(); + $output->writeln("$line"); + } else { + \Log::debug("[DataMigrator] $line"); + } + } + + /** + * Set migration queue option. Use this if you need to pass + * some data between queue processes. + */ + public function setOption(string $name, $value): void + { + $this->options[$name] = $value; + + if ($this->queue) { + $this->queue->data = $this->queueData(); + $this->queue->save(); + } + } + + /** + * Create a queue for the request + * + * @param string $queue_id Unique queue identifier + */ + protected function createQueue(string $queue_id): void + { + $this->queue = new Queue(); + $this->queue->id = $queue_id; + $this->queue->data = $this->queueData(); + $this->queue->save(); + } + + /** + * Prepare queue data + */ + protected function queueData() + { + $options = $this->options; + unset($options['stdout']); // jobs aren't in stdout anymore + + // TODO: data should be encrypted + return [ + 'source' => (string) $this->source, + 'destination' => (string) $this->destination, + 'options' => $options, + ]; + } + + /** + * Initialize environment for job execution + * + * @param string $queueId Queue identifier + */ + protected function envFromQueue(string $queueId): void + { + $this->queue = Queue::findOrFail($queueId); + + $this->source = new Account($this->queue->data['source']); + $this->destination = new Account($this->queue->data['destination']); + $this->options = $this->queue->data['options']; + + $this->importer = $this->initDriver($this->destination, ImporterInterface::class); + $this->exporter = $this->initDriver($this->source, ExporterInterface::class); + } + + /** + * Initialize (and select) migration driver + */ + protected function initDriver(Account $account, string $interface) + { + switch ($account->scheme) { + case 'ews': + $driver = new EWS($account, $this); + break; + + case 'dav': + case 'davs': + $driver = new DAV($account, $this); + break; + /* + case 'imap': + case 'imaps': + $driver = new IMAP($account, $this); + break; + */ + + default: + throw new \Exception("Failed to init driver for '{$account->scheme}'"); + } + + // Make sure driver is used in the direction it supports + if (!is_a($driver, $interface)) { + throw new \Exception(sprintf( + "'%s' driver does not implement %s", + class_basename($driver), + class_basename($interface) + )); + } + + return $driver; + } +} diff --git a/src/app/DataMigrator/Interface/ExporterInterface.php b/src/app/DataMigrator/Interface/ExporterInterface.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/Interface/ExporterInterface.php @@ -0,0 +1,36 @@ + $value) { + $obj->{$key} = $value; + } + + return $obj; + } +} diff --git a/src/app/DataMigrator/Interface/ImporterInterface.php b/src/app/DataMigrator/Interface/ImporterInterface.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/Interface/ImporterInterface.php @@ -0,0 +1,40 @@ + $value) { + $obj->{$key} = $value; + } + + return $obj; + } +} diff --git a/src/app/DataMigrator/Jobs/FolderJob.php b/src/app/DataMigrator/Jobs/FolderJob.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/Jobs/FolderJob.php @@ -0,0 +1,64 @@ +folder = $folder; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $migrator = new Engine(); + $migrator->processFolder($this->folder); + } + + /** + * The job failed to process. + * + * @param \Exception $exception + * + * @return void + */ + public function failed(\Exception $exception) + { + // TODO: Count failed jobs in the queue + // I'm not sure how to do this after the final failure (after X tries) + // In other words how do we know all jobs in a queue finished (successfully or not) + // Probably we have to set $tries = 1 + } +} diff --git a/src/app/DataMigrator/Jobs/ItemJob.php b/src/app/DataMigrator/Jobs/ItemJob.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/Jobs/ItemJob.php @@ -0,0 +1,64 @@ +item = $item; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $migrator = new Engine(); + $migrator->processItem($this->item); + } + + /** + * The job failed to process. + * + * @param \Exception $exception + * + * @return void + */ + public function failed(\Exception $exception) + { + // TODO: Count failed jobs in the queue + // I'm not sure how to do this after the final failure (after X tries) + // In other words how do we know all jobs in a queue finished (successfully or not) + // Probably we have to set $tries = 1 + } +} diff --git a/src/app/DataMigrator/Queue.php b/src/app/DataMigrator/Queue.php new file mode 100644 --- /dev/null +++ b/src/app/DataMigrator/Queue.php @@ -0,0 +1,78 @@ + + */ + protected $casts = ['data' => 'array']; + + /** + * The model's default values for attributes. + * + * @var array + */ + protected $attributes = [ + 'jobs_started' => 0, + 'jobs_finished' => 0, + 'data' => '', // must not be [] + ]; + + + /** + * Fast and race-condition free method of bumping the jobs_started value + */ + public function bumpJobsStarted(int $num = null) + { + DB::update( + "update data_migrator_queues set jobs_started = jobs_started + ? where id = ?", + [$num ?: 1, $this->id] + ); + } + + /** + * Fast and race-condition free method of bumping the jobs_finished value + */ + public function bumpJobsFinished(int $num = null) + { + DB::update( + "update data_migrator_queues set jobs_finished = jobs_finished + ? where id = ?", + [$num ?: 1, $this->id] + ); + } +} diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -19,6 +19,7 @@ "barryvdh/laravel-dompdf": "^2.0.1", "doctrine/dbal": "^3.6", "dyrynda/laravel-nullable-fields": "^4.3.0", + "garethp/php-ews": "dev-master", "guzzlehttp/guzzle": "^7.8.0", "kolab/net_ldap3": "dev-master", "laravel/framework": "^10.15.0", diff --git a/src/database/migrations/2019_11_21_110000_create_data_migrator_queues_table.php b/src/database/migrations/2019_11_21_110000_create_data_migrator_queues_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2019_11_21_110000_create_data_migrator_queues_table.php @@ -0,0 +1,36 @@ +string('id', 32); + $table->integer('jobs_started'); + $table->integer('jobs_finished'); + $table->mediumText('data'); + $table->timestamps(); + + $table->primary('id'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('data_migrator_queues'); + } +}; diff --git a/src/phpstan.neon b/src/phpstan.neon --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -4,6 +4,7 @@ ignoreErrors: - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' - '#Call to an undefined method Tests\\Browser::#' + - '#Call to an undefined method garethp\\ews\\API\\Type::#' level: 5 parallel: processTimeout: 300.0