Page MenuHomePhorge

D5092.1775284003.diff
No OneTemporary

Authored By
Unknown
Size
59 KB
Referenced Files
None
Subscribers
None

D5092.1775284003.diff

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
@@ -18,6 +18,12 @@
self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav',
];
+ public const SHARING_READ = 'read';
+ public const SHARING_READ_WRITE = 'read-write';
+ public const SHARING_NO_ACCESS = 'no-access';
+ public const SHARING_OWNER = 'shared-owner';
+ public const SHARING_NOT_SHARED = 'not-shared';
+
protected $url;
protected $user;
protected $password;
@@ -470,6 +476,58 @@
return $objects;
}
+ /**
+ * Set folder sharing invites (draft-pot-webdav-resource-sharing)
+ *
+ * @param string $location Resource (folder) location
+ * @param array $sharees Map of sharee => privilege
+ *
+ * @return bool True on success, False on error
+ */
+ public function shareResource(string $location, array $sharees): bool
+ {
+ // TODO: This might need to be configurable or discovered somehow
+ $path = '/principals/user/';
+ if ($host_path = parse_url($this->url, PHP_URL_PATH)) {
+ $path = '/' . trim($host_path, '/') . $path;
+ }
+
+ $props = '';
+
+ foreach ($sharees as $href => $sharee) {
+ if (!is_array($sharee)) {
+ $sharee = ['access' => $sharee];
+ }
+
+ $href = $path . $href;
+ $props .= '<d:sharee>'
+ . '<d:href>' . htmlspecialchars($href, ENT_XML1, 'UTF-8') . '</d:href>'
+ . '<d:share-access><d:' . ($sharee['access'] ?? self::SHARING_NO_ACCESS) . '/></d:share-access>'
+ . '<d:' . ($sharee['status'] ?? 'noresponse') . '/>';
+
+ if (isset($sharee['comment']) && strlen($sharee['comment'])) {
+ $props .= '<d:comment>' . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8') . '</d:comment>';
+ }
+
+ if (isset($sharee['displayname']) && strlen($sharee['displayname'])) {
+ $props .= '<d:prop><d:displayname>'
+ . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8')
+ . '</d:displayname></d:prop>';
+ }
+
+ $props .= '</d:sharee>';
+ }
+
+ $headers = ['Content-Type' => 'application/davsharing+xml; charset=utf-8'];
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:share-resource xmlns:d="DAV:">' . $props . '</d:share-resource>';
+
+ $response = $this->request($location, 'POST', $body, $headers);
+
+ return $response !== false;
+ }
+
/**
* Fetch DAV objects data from a folder
*
diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php
--- a/src/app/Backends/DAV/Folder.php
+++ b/src/app/Backends/DAV/Folder.php
@@ -22,6 +22,9 @@
/** @var ?string Folder color (calendar-color property) */
public $color;
+ /** @var array Folder 'invites' property */
+ public $invites = [];
+
/** @var ?string Folder owner (email) */
public $owner;
@@ -72,6 +75,9 @@
}
}
+ $folder->types = $types;
+ $folder->components = $components;
+
if ($owner = $element->getElementsByTagName('owner')->item(0)) {
if ($owner->firstChild) {
$href = $owner->firstChild->nodeValue; // owner principal href
@@ -81,8 +87,52 @@
}
}
- $folder->types = $types;
- $folder->components = $components;
+ // 'invite' from draft-pot-webdav-resource-sharing
+ if ($invite_element = $element->getElementsByTagName('invite')->item(0)) {
+ $invites = [];
+ foreach ($invite_element->childNodes as $sharee) {
+ /** @var \DOMElement $sharee */
+ $href = $sharee->getElementsByTagName('href')->item(0)->nodeValue;
+ $status = 'noresponse';
+
+ if ($comment = $sharee->getElementsByTagName('comment')->item(0)) {
+ $comment = $comment->nodeValue;
+ }
+
+ if ($displayname = $sharee->getElementsByTagName('displayname')->item(0)) {
+ $displayname = $displayname->nodeValue;
+ }
+
+ if ($access = $sharee->getElementsByTagName('share-access')->item(0)) {
+ $access = $access->firstChild->localName;
+ } else {
+ $access = \App\Backends\DAV::SHARING_NOT_SHARED;
+ }
+
+ $props = [
+ 'invite-noresponse',
+ 'invite-accepted',
+ 'invite-declined',
+ 'invite-invalid',
+ 'invite-deleted',
+ ];
+
+ foreach ($props as $name) {
+ if ($node = $sharee->getElementsByTagName($name)->item(0)) {
+ $status = str_replace('invite-', '', $node->localName);
+ }
+ }
+
+ $invites[$href] = [
+ 'access' => $access,
+ 'status' => $status,
+ 'comment' => $comment,
+ 'displayname' => $displayname,
+ ];
+ }
+
+ $folder->invites = $invites;
+ }
return $folder;
}
@@ -165,6 +215,7 @@
// . '<d:current-user-privilege-set/>'
. '<d:resourcetype/>'
. '<d:displayname/>'
+ . '<d:invite/>'
// . '<k:alarms/>'
. '</d:prop>'
. '</d:propfind>';
diff --git a/src/app/Backends/Storage.php b/src/app/Backends/Storage.php
--- a/src/app/Backends/Storage.php
+++ b/src/app/Backends/Storage.php
@@ -106,6 +106,27 @@
return $response;
}
+ /**
+ * File content getter.
+ *
+ * @param \App\Fs\Item $file File object
+ *
+ * @throws \Exception
+ */
+ public static function fileFetch(Item $file): string
+ {
+ $output = '';
+
+ $file->chunks()->orderBy('sequence')->get()->each(function ($chunk) use ($file, &$output) {
+ $disk = LaravelStorage::disk(\config('filesystems.default'));
+ $path = Storage::chunkLocation($chunk->chunk_id, $file);
+
+ $output .= $disk->read($path);
+ });
+
+ return $output;
+ }
+
/**
* File upload handler
*
@@ -212,7 +233,6 @@
throw new \Exception("Invalid 'from' parameter for resumable file upload.");
}
-
$disk = LaravelStorage::disk(\config('filesystems.default'));
$chunkId = \App\Utils::uuidStr();
diff --git a/src/app/DataMigrator/Account.php b/src/app/DataMigrator/Account.php
--- a/src/app/DataMigrator/Account.php
+++ b/src/app/DataMigrator/Account.php
@@ -2,6 +2,8 @@
namespace App\DataMigrator;
+use App\User;
+
/**
* Data object representing user account on an external service
*/
@@ -37,6 +39,9 @@
/** @var string Full account definition */
protected $input;
+ /** @var ?User User object */
+ protected $user;
+
/**
* Object constructor
@@ -115,6 +120,20 @@
return $this->input;
}
+ /**
+ * Returns User object assiciated with the account (if it is a local account)
+ *
+ * @return ?User User object if found
+ */
+ public function getUser(): ?User
+ {
+ if (!$this->user && $this->email) {
+ $this->user = User::where('email', $this->email)->first();
+ }
+
+ return $this->user;
+ }
+
/**
* Parse file URI
*/
diff --git a/src/app/DataMigrator/Driver/DAV.php b/src/app/DataMigrator/Driver/DAV.php
--- a/src/app/DataMigrator/Driver/DAV.php
+++ b/src/app/DataMigrator/Driver/DAV.php
@@ -13,6 +13,7 @@
use App\DataMigrator\Interface\ImporterInterface;
use App\DataMigrator\Interface\Item;
use App\DataMigrator\Interface\ItemSet;
+use App\User;
use App\Utils;
use Illuminate\Http\Client\RequestException;
@@ -121,29 +122,62 @@
// 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.
+ $href = false;
foreach ($folders as $dav_folder) {
if (str_replace(' » ', '/', $dav_folder->name) === $folder->fullname) {
- // do nothing, folder already exists
- return;
+ $href = $dav_folder->href;
+ break;
}
}
- $home = $this->client->getHome($dav_type);
- $folder_id = Utils::uuidStr();
- $collection_type = $dav_type == DAVClient::TYPE_VCARD ? 'addressbook' : 'calendar';
+ if (!$href) {
+ $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 = $href = rtrim($home, '/') . '/' . $folder_id;
+ $dav_folder->components = [$dav_type];
+ $dav_folder->types = ['collection', $collection_type];
- // 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 {$href}");
+ }
+ }
+
+ // Sharing (Cyrus/Kolab4 style)
+ if (!empty($folder->acl)) {
+ // Note: We assume the destination account is controlled by this cockpit instance
+ $emails = array_diff(array_keys($folder->acl), [$this->account->email]);
+ $acl = [];
+
+ foreach (User::whereIn('email', $emails)->pluck('email') as $email) {
+ $rights = $folder->acl[$email];
+ if (in_array('w', $rights) || in_array('a', $rights)) {
+ $acl[$email] = DAVClient::SHARING_READ_WRITE;
+ } elseif (in_array('r', $rights)) {
+ $acl[$email] = DAVClient::SHARING_READ;
+ }
+ }
- if ($this->client->folderCreate($dav_folder) === false) {
- throw new \Exception("Failed to create a DAV folder {$dav_folder->href}");
+ if (!empty($acl)) {
+ if ($this->client->shareResource($href, $acl) === false) {
+ \Log::warning("Failed to set sharees on the folder: {$href}");
+ }e
+ }
}
}
+ /**
+ * Fetching a folder metadata
+ */
+ public function fetchFolder(Folder $folder): void
+ {
+ // NOP
+ }
+
/**
* Fetching an item
*/
@@ -183,7 +217,7 @@
$existing = $importer->getItems($folder);
$dav_type = $this->type2DAV($folder->type);
- $location = $this->getFolderPath($folder);
+ $location = $folder->id ?? $this->getFolderPath($folder);
$search = new DAVSearch($dav_type, false);
// TODO: We request only properties relevant to incremental migration,
@@ -352,7 +386,7 @@
$result[$folder->href] = Folder::fromArray([
'fullname' => str_replace(' » ', '/', $folder->name),
- 'href' => $folder->href,
+ 'id' => $folder->href,
'type' => $type,
]);
}
@@ -364,7 +398,7 @@
/**
* Get folder relative URI
*/
- protected function getFolderPath(Folder $folder): string
+ public function getFolderPath(Folder $folder): string
{
$cache_key = $folder->type . '!' . $folder->fullname;
if (isset($this->folderPaths[$cache_key])) {
diff --git a/src/app/DataMigrator/Driver/EWS.php b/src/app/DataMigrator/Driver/EWS.php
--- a/src/app/DataMigrator/Driver/EWS.php
+++ b/src/app/DataMigrator/Driver/EWS.php
@@ -309,6 +309,14 @@
return $result;
}
+ /**
+ * Fetching a folder metadata
+ */
+ public function fetchFolder(Folder $folder): void
+ {
+ // NOP
+ }
+
/**
* Fetch a list of folder items
*/
diff --git a/src/app/DataMigrator/Driver/IMAP.php b/src/app/DataMigrator/Driver/IMAP.php
--- a/src/app/DataMigrator/Driver/IMAP.php
+++ b/src/app/DataMigrator/Driver/IMAP.php
@@ -9,6 +9,7 @@
use App\DataMigrator\Interface\ImporterInterface;
use App\DataMigrator\Interface\Item;
use App\DataMigrator\Interface\ItemSet;
+use App\User;
/**
* Data migration from IMAP
@@ -38,7 +39,7 @@
// TODO: Move this to self::authenticate()?
$config = self::getConfig($account);
- $this->imap = self::initIMAP($config);
+ $this->imap = static::initIMAP($config);
}
/**
@@ -74,14 +75,9 @@
throw new \Exception("IMAP does not support folder of type {$folder->type}");
}
- if ($folder->targetname == 'INBOX') {
- // INBOX always exists
- return;
- }
-
$mailbox = self::toUTF7($folder->targetname);
- if (!$this->imap->createFolder($mailbox)) {
+ if ($mailbox != 'INBOX' && !$this->imap->createFolder($mailbox)) {
if (str_contains($this->imap->error, "Mailbox already exists")) {
// Not an error
\Log::debug("Folder already exists: {$mailbox}");
@@ -96,6 +92,25 @@
\Log::warning("Failed to subscribe to the folder: {$this->imap->error}");
}
}
+
+ // ACL
+ if (!empty($folder->acl)) {
+ // Note: We assume the destination account is controlled by this cockpit instance
+ $emails = array_diff(array_keys($folder->acl), [$this->account->email]);
+ foreach (User::whereIn('email', $emails)->pluck('email') as $email) {
+ if (!$this->imap->setACL($mailbox, $email, $folder->acl[$email])) {
+ \Log::warning("Failed to set ACL for {$email} on the folder: {$this->imap->error}");
+ }
+ }
+ /*
+ // FIXME: Looks like this might not work on Cyrus IMAP depending on config
+ if (!empty($folder->acl['anyone'])) {
+ if (!$this->imap->setACL($mailbox, 'anyone', $folder->acl['anyone'])) {
+ \Log::warning("Failed to set ACL for anyone on the folder: {$this->imap->error}");
+ }
+ }
+ */
+ }
}
/**
@@ -160,6 +175,29 @@
}
}
+ /**
+ * Fetching a folder metadata
+ */
+ public function fetchFolder(Folder $folder): void
+ {
+ $mailbox = self::toUTF7($folder->targetname);
+
+ // Get folder ACL
+ if ($this->imap->getCapability('ACL')) {
+ // TODO: Support RIGHTS= capability
+ $acl = $this->imap->getACL($mailbox);
+ if (is_array($acl)) {
+ $acl = array_change_key_case($acl, \CASE_LOWER);
+ // owner should always have full rights, no need to migrate this entry,
+ // and it would be problematic if the source email is different than the destination email
+ unset($acl[$this->account->email]);
+ $folder->acl = $acl;
+ } else {
+ \Log::warning("Failed to get ACL for the folder: {$this->imap->error}");
+ }
+ }
+ }
+
/**
* Fetching an item
*/
diff --git a/src/app/DataMigrator/Driver/Kolab.php b/src/app/DataMigrator/Driver/Kolab.php
--- a/src/app/DataMigrator/Driver/Kolab.php
+++ b/src/app/DataMigrator/Driver/Kolab.php
@@ -16,11 +16,18 @@
{
protected const CTYPE_KEY = '/shared/vendor/kolab/folder-type';
protected const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type';
+ protected const UID_KEY_SHARED = '/shared/vendor/kolab/uniqueid';
+ protected const UID_KEY_CYRUS = '/shared/vendor/cmu/cyrus-imapd/uniqueid';
protected const DAV_TYPES = [
Engine::TYPE_CONTACT,
Engine::TYPE_EVENT,
Engine::TYPE_TASK,
];
+ protected const IMAP_TYPES = [
+ Engine::TYPE_MAIL,
+ Engine::TYPE_CONFIGURATION,
+ Engine::TYPE_FILE
+ ];
/** @var DAV DAV importer/exporter engine */
protected $davDriver;
@@ -87,6 +94,12 @@
$this->davDriver->createFolder($folder);
return;
}
+
+ // Files
+ if ($folder->type == Engine::TYPE_FILE) {
+ Kolab\Files::createFolder($this->account, $folder);
+ return;
+ }
}
/**
@@ -115,6 +128,12 @@
return;
}
+ // Files
+ if ($item->folder->type == Engine::TYPE_FILE) {
+ Kolab\Files::saveKolab4File($this->account, $item);
+ return;
+ }
+
// Configuration (v3 tags)
if ($item->folder->type == Engine::TYPE_CONFIGURATION) {
Kolab\Tags::migrateKolab3Tag($this->imap, $item);
@@ -122,6 +141,24 @@
}
}
+ /**
+ * Fetching a folder metadata
+ */
+ public function fetchFolder(Folder $folder): void
+ {
+ // No support for migration from Kolab4 yet
+ if ($this->account->scheme != 'kolab3') {
+ throw new \Exception("Kolab v4 source not supported");
+ }
+
+ // IMAP (and DAV)
+ // TODO: We can treat 'file' folders the same, but we have no sharing in Kolab4 yet for them
+ if ($folder->type == Engine::TYPE_MAIL || in_array($folder->type, self::DAV_TYPES)) {
+ parent::fetchFolder($folder);
+ return;
+ }
+ }
+
/**
* Fetching an item
*/
@@ -144,6 +181,12 @@
return;
}
+ // Files (IMAP)
+ if ($item->folder->type == Engine::TYPE_FILE) {
+ Kolab\Files::fetchKolab3File($this->imap, $item);
+ return;
+ }
+
// Configuration (v3 tags)
if ($item->folder->type == Engine::TYPE_CONFIGURATION) {
Kolab\Tags::fetchKolab3Tag($this->imap, $item);
@@ -173,6 +216,19 @@
return;
}
+ // Files
+ if ($folder->type == Engine::TYPE_FILE) {
+ // Get existing files from the destination account
+ $existing = $importer->getItems($folder);
+
+ $mailbox = self::toUTF7($folder->fullname);
+ foreach (Kolab\Files::getKolab3Files($this->imap, $mailbox, $existing) as $file) {
+ $file['folder'] = $folder;
+ $item = Item::fromArray($file);
+ $callback($item);
+ }
+ }
+
// Configuration (v3 tags)
if ($folder->type == Engine::TYPE_CONFIGURATION) {
// Get existing tags from the destination account
@@ -193,24 +249,14 @@
public function getFolders($types = []): array
{
// Note: No support for migration from Kolab4 yet.
+ // We use IMAP to get the list of all folders, but we'll get folder contents from IMAP and DAV.
+ // This will not work with Kolab4
if ($this->account->scheme != 'kolab3') {
throw new \Exception("Kolab v4 source not supported");
}
- // Using only IMAP to get the list of all folders works with Kolab v3, but not v4.
- // We could use IMAP, extract the XML, convert to iCal/vCard format and pass to DAV.
- // But it will be easier to use DAV for contact/task/event folders migration.
$result = [];
- // Get DAV folders
- if (empty($types) || count(array_intersect($types, self::DAV_TYPES)) > 0) {
- $result = $this->davDriver->getFolders($types);
- }
-
- if (!empty($types) && count(array_intersect($types, [Engine::TYPE_MAIL, Engine::TYPE_CONFIGURATION])) == 0) {
- return $result;
- }
-
// Get IMAP (mail and configuration) folders
$folders = $this->imap->listMailboxes('', '', ['SUBSCRIBED']);
@@ -218,7 +264,14 @@
throw new \Exception("Failed to get list of IMAP folders");
}
- $metadata = $this->imap->getMetadata('*', [self::CTYPE_KEY, self::CTYPE_KEY_PRIVATE]);
+ $meta_keys = [
+ self::CTYPE_KEY,
+ self::CTYPE_KEY_PRIVATE,
+ self::UID_KEY_SHARED,
+ self::UID_KEY_CYRUS,
+ ];
+
+ $metadata = $this->imap->getMetadata('*', $meta_keys);
if ($metadata === null) {
throw new \Exception("Failed to get METADATA for IMAP folders. Not a Kolab server?");
@@ -227,17 +280,19 @@
$configuration_folders = [];
foreach ($folders as $folder) {
+ $folder_meta = $metadata[$folder] ?? [];
+
$type = 'mail';
- if (!empty($metadata[$folder][self::CTYPE_KEY_PRIVATE])) {
- $type = $metadata[$folder][self::CTYPE_KEY_PRIVATE];
- } elseif (!empty($metadata[$folder][self::CTYPE_KEY])) {
- $type = $metadata[$folder][self::CTYPE_KEY];
+ if (!empty($folder_meta[self::CTYPE_KEY_PRIVATE])) {
+ $type = $folder_meta[self::CTYPE_KEY_PRIVATE];
+ } elseif (!empty($folder_meta[self::CTYPE_KEY])) {
+ $type = $folder_meta[self::CTYPE_KEY];
}
[$type] = explode('.', $type);
// These types we do not support
- if ($type != Engine::TYPE_MAIL && $type != Engine::TYPE_CONFIGURATION) {
+ if (!in_array($type, self::IMAP_TYPES) && !in_array($type, self::DAV_TYPES)) {
continue;
}
@@ -260,6 +315,23 @@
'subscribed' => $is_subscribed || $folder === 'INBOX',
]);
+ $uid = $folder_meta[self::UID_KEY_SHARED] ?? $folder_meta[self::UID_KEY_CYRUS] ?? null;
+
+ if (in_array($type, self::DAV_TYPES)) {
+ if (empty($uid)) {
+ throw new \Exception("Missing UID for folder {$folder->fullname}");
+ }
+
+ // In tests we're using Cyrus to emulate iRony, but it uses different folder paths/names
+ if (!empty($this->account->params['v4dav'])) {
+ // Note: Use this code path (option) for tests
+ $folder->id = $this->davDriver->getFolderPath($folder);
+ } else {
+ $path = $type == 'contact' ? 'addressbooks' : 'calendars';
+ $folder->id = sprintf('/%s/%s/%s', $path, $this->account->email, $uid);
+ }
+ }
+
if ($type == Engine::TYPE_CONFIGURATION) {
$configuration_folders[] = $folder;
} else {
@@ -297,11 +369,29 @@
return $this->davDriver->getItems($folder);
}
- // Configuration folder (v3 tags)
+ // Files
+ if ($folder->type == Engine::TYPE_FILE) {
+ return Kolab\Files::getKolab4Files($this->account, $folder);
+ }
+
+ // Configuration folder (tags)
if ($folder->type == Engine::TYPE_CONFIGURATION) {
return Kolab\Tags::getKolab4Tags($this->imap);
}
return [];
}
+
+ /**
+ * Initialize IMAP connection and authenticate the user
+ */
+ protected static function initIMAP(array $config): \rcube_imap_generic
+ {
+ $imap = parent::initIMAP($config);
+
+ // Advertise itself as a Kolab client, in case Guam is in the way
+ $imap->id(['name' => 'Cockpit/Kolab']);
+
+ return $imap;
+ }
}
diff --git a/src/app/DataMigrator/Driver/Kolab/Files.php b/src/app/DataMigrator/Driver/Kolab/Files.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Driver/Kolab/Files.php
@@ -0,0 +1,309 @@
+<?php
+
+namespace App\DataMigrator\Driver\Kolab;
+
+use App\Backends\Storage;
+use App\DataMigrator\Account;
+use App\DataMigrator\Engine;
+use App\DataMigrator\Interface\Folder;
+use App\DataMigrator\Interface\Item;
+use App\Fs\Item as FsItem;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * Utilities to handle/migrate Kolab (v3 and v4) files
+ */
+class Files
+{
+ /**
+ * Create a Kolab4 files collection (folder)
+ *
+ * @param Account $account Destination account
+ * @param Folder $folder Folder object
+ */
+ public static function createFolder(Account $account, Folder $folder): void
+ {
+ // We assume destination is the local server. Maybe we should be using Cockpit API?
+ self::getFsCollection($account, $folder, true);
+ }
+
+ /**
+ * Get file properties/content
+ *
+ * @param \rcube_imap_generic $imap IMAP client (account)
+ * @param Item $item File item
+ */
+ public static function fetchKolab3File($imap, Item $item): void
+ {
+ // Handle file content in memory (up to 20MB), bigger files will use a temp file
+ if (!isset($item->data['size']) || $item->data['size'] > Engine::MAX_ITEM_SIZE) {
+ // Save the message content to a file
+ $location = $item->folder->tempFileLocation($item->id . '.eml');
+
+ $fp = fopen($location, 'w');
+
+ if (!$fp) {
+ throw new \Exception("Failed to open 'php://temp' stream");
+ }
+ }
+
+ $mailbox = $item->data['mailbox'];
+
+ $result = $imap->handlePartBody($mailbox, $item->id, true, 3, $item->data['encoding'], null, $fp ?? null);
+
+ if ($result === false) {
+ if (!empty($fp)) {
+ fclose($fp);
+ }
+
+ throw new \Exception("Failed to fetch IMAP message attachment for {$mailbox}/{$item->id}");
+ }
+
+ if (!empty($fp) && !empty($location)) {
+ $item->filename = $location;
+ fclose($fp);
+ } else {
+ $item->content = $result;
+ }
+ }
+
+ /**
+ * Get files from Kolab3 (IMAP) folder
+ *
+ * @param \rcube_imap_generic $imap IMAP client (account)
+ * @param string $mailbox Folder name
+ * @param array $existing Files existing at the destination account
+ */
+ public static function getKolab3Files($imap, $mailbox, $existing = []): array
+ {
+ // Find file objects
+ $search = 'NOT DELETED HEADER X-Kolab-Type "application/x-vnd.kolab.file"';
+ $search = $imap->search($mailbox, $search, true);
+ if ($search->is_empty()) {
+ return [];
+ }
+
+ // Get messages' basic headers, include headers for the XML attachment
+ // TODO: Limit data in FETCH, we need only INTERNALDATE and BODY.PEEK[3.MIME].
+ $uids = $search->get_compressed();
+ $messages = $imap->fetchHeaders($mailbox, $uids, true, false, [], ['BODY.PEEK[3.MIME]']);
+ $files = [];
+
+ foreach ($messages as $message) {
+ [$type, $name, $size, $encoding] = self::parseHeaders($message->bodypart['3.MIME'] ?? '');
+
+ // Sanity check
+ if ($name === null || $type === null) {
+ continue;
+ }
+
+ // Note: We do not really need to fetch and parse Kolab XML
+
+ $mtime = \rcube_utils::anytodatetime($message->internaldate, new \DateTimeZone('UTC'));
+ $exists = $existing[$name] ?? null;
+
+ if ($exists && $exists->updated_at == $mtime) {
+ // No changes to the file, skip it
+ continue;
+ }
+
+ $files[] = [
+ 'id' => $message->uid,
+ 'existing' => $exists,
+ 'data' => [
+ 'name' => $name,
+ 'mailbox' => $mailbox,
+ 'encoding' => $encoding,
+ 'type' => $type,
+ 'size' => $size,
+ 'mtime' => $mtime,
+ ],
+ ];
+ }
+
+ return $files;
+ }
+
+ /**
+ * Get list of Kolab4 files
+ *
+ * @param Account $account Destination account
+ * @param Folder $folder Folder
+ */
+ public static function getKolab4Files(Account $account, Folder $folder): array
+ {
+ // We assume destination is the local server. Maybe we should be using Cockpit API?
+ $collection = self::getFsCollection($account, $folder, false);
+
+ if (!$collection) {
+ return [];
+ }
+
+ return $collection->children()
+ ->select('fs_items.*')
+ ->addSelect(DB::raw("(select value from fs_properties where fs_properties.item_id = fs_items.id"
+ . " and fs_properties.key = 'name') as name"))
+ ->where('type', '&', FsItem::TYPE_FILE)
+ ->whereNot('type', '&', FsItem::TYPE_INCOMPLETE)
+ ->get()
+ ->keyBy('name')
+ ->all();
+ }
+
+ /**
+ * Save a file into Kolab4 storage
+ *
+ * @param Account $account Destination account
+ * @param Item $item File item
+ */
+ public static function saveKolab4File(Account $account, Item $item): void
+ {
+ // We assume destination is the local server. Maybe we should be using Cockpit API?
+ $collection = self::getFsCollection($account, $item->folder, false);
+
+ if (!$collection) {
+ throw new \Exception("Failed to find destination collection for {$item->folder->fullname}");
+ }
+
+ $params = ['mimetype' => $item->data['type']];
+
+ DB::beginTransaction();
+
+ if ($item->existing) {
+ /** @var FsItem $file */
+ $file = $item->existing;
+ $file->updated_at = $item->data['mtime'];
+ $file->timestamps = false;
+ $file->save();
+ } else {
+ $file = new FsItem();
+ $file->user_id = $account->getUser()->id;
+ $file->type = FsItem::TYPE_FILE;
+ $file->updated_at = $item->data['mtime'];
+ $file->timestamps = false;
+ $file->save();
+
+ $file->properties()->create(['key' => 'name', 'value' => $item->data['name']]);
+ $collection->relations()->create(['related_id' => $file->id]);
+ }
+
+ if ($item->filename) {
+ $fp = fopen($item->filename, 'r');
+ } else {
+ $fp = fopen('php://memory', 'r+');
+ fwrite($fp, $item->content);
+ rewind($fp);
+ }
+
+ // TODO: Use proper size chunks
+
+ Storage::fileInput($fp, $params, $file);
+
+ DB::commit();
+
+ fclose($fp);
+ }
+
+ /**
+ * Find (and optionally create) a Kolab4 files collection
+ *
+ * @param Account $account Destination account
+ * @param Folder $folder Folder object
+ * @param bool $create Create collection(s) if it does not exist
+ *
+ * @return ?FsItem Collection object if found
+ */
+ protected static function getFsCollection(Account $account, Folder $folder, bool $create = false)
+ {
+ if (!empty($folder->data['collection'])) {
+ return $folder->data['collection'];
+ }
+
+ // We assume destination is the local server. Maybe we should be using Cockpit API?
+ $user = $account->getUser();
+
+ // TODO: For now we assume '/' is the IMAP hierarchy separator. This may not work with dovecot.
+ $path = explode('/', $folder->fullname);
+ $collection = null;
+
+ // Create folder (and the whole tree) if it does not exist yet
+ foreach ($path as $name) {
+ $result = $user->fsItems()->select('fs_items.*');
+
+ if ($collection) {
+ $result->join('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id')
+ ->where('fs_relations.item_id', $collection->id);
+ } else {
+ $result->leftJoin('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id')
+ ->whereNull('fs_relations.related_id');
+ }
+
+ $found = $result->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id')
+ ->where('type', '&', FsItem::TYPE_COLLECTION)
+ ->where('key', 'name')
+ ->where('value', $name)
+ ->first();
+
+ if (!$found) {
+ if ($create) {
+ DB::beginTransaction();
+ $col = $user->fsItems()->create(['type' => FsItem::TYPE_COLLECTION]);
+ $col->properties()->create(['key' => 'name', 'value' => $name]);
+ if ($collection) {
+ $collection->relations()->create(['related_id' => $col->id]);
+ }
+ $collection = $col;
+ DB::commit();
+ } else {
+ return null;
+ }
+ } else {
+ $collection = $found;
+ }
+ }
+
+ $folder->data['collection'] = $collection;
+
+ return $collection;
+ }
+
+ /**
+ * Parse mail attachment headers (get content type, file name and size)
+ */
+ protected static function parseHeaders(string $input): array
+ {
+ // Parse headers
+ $headers = \rcube_mime::parse_headers($input);
+
+ $ctype = $headers['content-type'] ?? '';
+ $disposition = $headers['content-disposition'] ?? '';
+ $tokens = preg_split('/;[\s\r\n\t]*/', $ctype . ';' . $disposition);
+ $params = [];
+
+ // Extract file parameters
+ foreach ($tokens as $token) {
+ // TODO: Use order defined by the parameter name not order of occurrence in the header
+ if (preg_match('/^(name|filename)\*([0-9]*)\*?="*([^"]+)"*/i', $token, $matches)) {
+ $key = strtolower($matches[1]);
+ $params["{$key}*"] = ($params["{$key}*"] ?? '') . $matches[3];
+ } elseif (str_starts_with($token, 'size=')) {
+ $params['size'] = (int) trim(substr($token, 5));
+ } elseif (str_starts_with($token, 'filename=')) {
+ $params['filename'] = trim(substr($token, 9), '" ');
+ }
+ }
+
+ $type = explode(';', $ctype)[0] ?? null;
+ $size = $params['size'] ?? null;
+ $encoding = $headers['content-transfer-encoding'] ?? null;
+ $name = $params['filename'] ?? null;
+
+ // Decode file name according to RFC 2231, Section 4
+ if (isset($params['filename*']) && preg_match("/^([^']*)'[^']*'(.*)$/", $params['filename*'], $m)) {
+ // Note: We ignore charset as it should be always UTF-8
+ $name = rawurldecode($m[2]);
+ }
+
+ return [$type, $name, $size, $encoding];
+ }
+}
diff --git a/src/app/DataMigrator/Driver/Takeout.php b/src/app/DataMigrator/Driver/Takeout.php
--- a/src/app/DataMigrator/Driver/Takeout.php
+++ b/src/app/DataMigrator/Driver/Takeout.php
@@ -101,6 +101,14 @@
return $folders;
}
+ /**
+ * Fetching a folder metadata
+ */
+ public function fetchFolder(Folder $folder): void
+ {
+ // NOP
+ }
+
/**
* Fetch a list of folder items
*/
diff --git a/src/app/DataMigrator/Driver/Test.php b/src/app/DataMigrator/Driver/Test.php
--- a/src/app/DataMigrator/Driver/Test.php
+++ b/src/app/DataMigrator/Driver/Test.php
@@ -77,6 +77,14 @@
self::$createdFolders[] = $folder;
}
+ /**
+ * Fetching a folder metadata
+ */
+ public function fetchFolder(Folder $folder): void
+ {
+ // NOP
+ }
+
/**
* Fetching an item
*/
diff --git a/src/app/DataMigrator/Engine.php b/src/app/DataMigrator/Engine.php
--- a/src/app/DataMigrator/Engine.php
+++ b/src/app/DataMigrator/Engine.php
@@ -171,6 +171,7 @@
// Create the folder on the destination server
if (empty($this->options['dry'])) {
+ $this->exporter->fetchFolder($folder);
$this->importer->createFolder($folder);
} else {
\Log::info("Dry run: Creating folder {$folder->targetname}");
@@ -228,8 +229,8 @@
$this->envFromQueue($item->folder->queueId);
}
- $this->exporter->fetchItem($item);
if (empty($this->options['dry'])) {
+ $this->exporter->fetchItem($item);
$this->importer->createItem($item);
} else {
\Log::info("Dry run: Creating item {$item->filename}");
diff --git a/src/app/DataMigrator/Interface/ExporterInterface.php b/src/app/DataMigrator/Interface/ExporterInterface.php
--- a/src/app/DataMigrator/Interface/ExporterInterface.php
+++ b/src/app/DataMigrator/Interface/ExporterInterface.php
@@ -29,6 +29,11 @@
*/
public function fetchItemList(Folder $folder, $callback, ImporterInterface $importer): void;
+ /**
+ * Fetching a folder metadata
+ */
+ public function fetchFolder(Folder $folder): void;
+
/**
* Fetching an item
*/
diff --git a/src/app/DataMigrator/Interface/Folder.php b/src/app/DataMigrator/Interface/Folder.php
--- a/src/app/DataMigrator/Interface/Folder.php
+++ b/src/app/DataMigrator/Interface/Folder.php
@@ -39,6 +39,12 @@
/** @var bool Folder subscription state */
public $subscribed = true;
+ /** @var array Access Control list (email => rights) */
+ public $acl = [];
+
+ /** @var array Extra (temporary, cache) data */
+ public $data = [];
+
/**
* Create Folder instance from an array
diff --git a/src/app/Fs/Item.php b/src/app/Fs/Item.php
--- a/src/app/Fs/Item.php
+++ b/src/app/Fs/Item.php
@@ -41,7 +41,7 @@
/**
- * COntent chunks of this item (file).
+ * Content chunks of this item (file).
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
@@ -156,14 +156,14 @@
private function storeProperty(string $key, $value): void
{
if ($value === null || $value === '') {
- // Note: We're selecting the record first, so observers can act
- if ($prop = $this->properties()->where('key', $key)->first()) {
- $prop->delete();
- }
+ $this->properties()->where('key', $key)->delete();
} else {
- $this->properties()->updateOrCreate(
- ['key' => $key],
- ['value' => $value]
+ // Note: updateOrCreate() uses two queries, but upsert() uses one
+ $this->properties()->upsert(
+ // Note: Setting 'item_id' here should not be needed after we migrate to Laravel v11
+ [['key' => $key, 'value' => $value, 'item_id' => $this->id]],
+ ['item_id', 'key'],
+ ['value']
);
}
}
diff --git a/src/app/Fs/Relation.php b/src/app/Fs/Relation.php
--- a/src/app/Fs/Relation.php
+++ b/src/app/Fs/Relation.php
@@ -18,4 +18,7 @@
/** @var string Database table name */
protected $table = 'fs_relations';
+
+ /** @var bool Indicates if the model should be timestamped. */
+ public $timestamps = false;
}
diff --git a/src/app/Http/Controllers/API/V4/FsController.php b/src/app/Http/Controllers/API/V4/FsController.php
--- a/src/app/Http/Controllers/API/V4/FsController.php
+++ b/src/app/Http/Controllers/API/V4/FsController.php
@@ -292,8 +292,8 @@
// Add properties
$result->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id')
- ->whereNot('type', '&', Item::TYPE_INCOMPLETE)
- ->where('key', 'name');
+ ->whereNot('type', '&', Item::TYPE_INCOMPLETE)
+ ->where('key', 'name');
if ($type) {
if ($type == self::TYPE_COLLECTION) {
@@ -320,6 +320,8 @@
// Process the result
$result = $result->map(
function ($file) {
+ // TODO: This is going to be 100 SELECT queries (with pageSize=100), we should get
+ // file properties using the main query
$result = $this->objectToClient($file);
$result['name'] = $file->name; // @phpstan-ignore-line
diff --git a/src/tests/BackendsTrait.php b/src/tests/BackendsTrait.php
--- a/src/tests/BackendsTrait.php
+++ b/src/tests/BackendsTrait.php
@@ -271,7 +271,7 @@
/**
* Append an email message to the IMAP folder
*/
- protected function imapAppend(Account $account, $folder, $filename, $flags = [], $date = null): string
+ protected function imapAppend(Account $account, $folder, $filename, $flags = [], $date = null, $replace = [])
{
$imap = $this->getImapClient($account);
@@ -284,6 +284,10 @@
$source = file_get_contents($source);
$source = preg_replace('/\r?\n/', "\r\n", $source);
+ foreach ($replace as $from => $to) {
+ $source = preg_replace($from, $to, $source);
+ }
+
$uid = $imap->append($folder, $source, $flags, $date, true);
if ($uid === false) {
diff --git a/src/tests/Feature/DataMigrator/KolabTest.php b/src/tests/Feature/DataMigrator/KolabTest.php
--- a/src/tests/Feature/DataMigrator/KolabTest.php
+++ b/src/tests/Feature/DataMigrator/KolabTest.php
@@ -2,10 +2,14 @@
namespace Tests\Feature\DataMigrator;
+use App\Backends\Storage;
use App\DataMigrator\Account;
use App\DataMigrator\Driver\Kolab\Tags as KolabTags;
use App\DataMigrator\Engine;
use App\DataMigrator\Queue as MigratorQueue;
+use App\Fs\Item as FsItem;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage as LaravelStorage;
use Tests\BackendsTrait;
use Tests\TestCase;
@@ -18,6 +22,9 @@
{
use BackendsTrait;
+ private static $skipTearDown = false;
+ private static $skipSetUp = false;
+
/**
* {@inheritDoc}
*/
@@ -25,7 +32,12 @@
{
parent::setUp();
- MigratorQueue::truncate();
+ if (!self::$skipSetUp) {
+ MigratorQueue::truncate();
+ FsItem::query()->forceDelete();
+ }
+
+ self::$skipSetUp = false;
}
/**
@@ -33,7 +45,17 @@
*/
public function tearDown(): void
{
- MigratorQueue::truncate();
+ if (!self::$skipTearDown) {
+ MigratorQueue::truncate();
+ FsItem::query()->forceDelete();
+
+ $disk = LaravelStorage::disk(\config('filesystems.default'));
+ foreach ($disk->listContents('') as $dir) {
+ $disk->deleteDirectory($dir->path());
+ }
+ }
+
+ self::$skipTearDown = false;
parent::tearDown();
}
@@ -52,6 +74,9 @@
$migrator = new Engine();
$migrator->migrate($src, $dst, ['force' => true, 'sync' => true]);
+ $imap = $this->getImapClient($dst_imap);
+ $dav = $this->getDavClient($dst_dav);
+
// Assert the migrated mail
$messages = $this->imapList($dst_imap, 'INBOX');
$messages = \collect($messages)->keyBy('messageID')->all();
@@ -78,13 +103,13 @@
$utf7_folder = \mb_convert_encoding('&kość', 'UTF7-IMAP', 'UTF8');
$this->assertContains($utf7_folder, $mail_folders);
- // Check migration of folder subscription state
+ // Check migration of folder subscription state and ACL
$subscribed = $this->imapListFolders($dst_imap, true);
$this->assertNotContains($utf7_folder, $subscribed);
$this->assertContains('Test2', $subscribed);
+ $this->assertSame('lrswi', implode('', $imap->getACL('Test2')['john@kolab.org']));
// Assert migrated tags
- $imap = $this->getImapClient($dst_imap);
$tags = KolabTags::getKolab4Tags($imap);
$this->assertCount(1, $tags);
$this->assertSame('tag', $tags[0]['name']);
@@ -99,7 +124,7 @@
$this->assertSame('Party', $events['abcdef']->summary);
$this->assertSame('Meeting', $events['123456']->summary);
- $events = $this->davList($dst_dav, 'Custom Calendar', Engine::TYPE_EVENT);
+ $events = $this->davList($dst_dav, 'Calendar/Custom Calendar', Engine::TYPE_EVENT);
$events = \collect($events)->keyBy('uid')->all();
$this->assertCount(1, $events);
$this->assertSame('Test Summary', $events['aaa-aaa']->summary);
@@ -117,6 +142,50 @@
$this->assertCount(2, $tasks);
$this->assertSame('Task1', $tasks['ccc-ccc']->summary);
$this->assertSame('Task2', $tasks['ddd-ddd']->summary);
+
+ // Assert migrated ACL/sharees on a DAV folder
+ $folder = $dav->folderInfo("/calendars/user/{$dst->email}/Default"); // 'Calendar' folder
+ $this->assertCount(1, $folder->invites);
+ $this->assertSame('read-write', $folder->invites['mailto:john@kolab.org']['access']);
+
+ // Assert migrated files
+ $user = $dst_imap->getUser();
+ $folders = $user->fsItems()->where('type', '&', FsItem::TYPE_COLLECTION)
+ ->select('fs_items.*')
+ ->addSelect(DB::raw("(select value from fs_properties where fs_properties.item_id = fs_items.id"
+ . " and fs_properties.key = 'name') as name"))
+ ->get()
+ ->keyBy('name')
+ ->all();
+ $files = $user->fsItems()->whereNot('type', '&', FsItem::TYPE_COLLECTION)
+ ->select('fs_items.*')
+ ->addSelect(DB::raw("(select value from fs_properties where fs_properties.item_id = fs_items.id"
+ . " and fs_properties.key = 'name') as name"))
+ ->get()
+ ->keyBy('name')
+ ->all();
+ $this->assertSame(2, count($folders));
+ $this->assertSame(3, count($files));
+ $this->assertArrayHasKey('A€B', $folders);
+ $this->assertArrayHasKey('Files', $folders);
+ $this->assertTrue($folders['Files']->children->contains($folders['A€B']));
+ $this->assertTrue($folders['Files']->children->contains($files['empty.txt']));
+ $this->assertTrue($folders['Files']->children->contains($files['&kość.odt']));
+ $this->assertTrue($folders['A€B']->children->contains($files['test2.odt']));
+ $this->assertEquals(10000, $files['&kość.odt']->getProperty('size'));
+ $this->assertEquals(0, $files['empty.txt']->getProperty('size'));
+ $this->assertEquals(10000, $files['test2.odt']->getProperty('size'));
+ $this->assertSame('application/vnd.oasis.opendocument.odt', $files['&kość.odt']->getProperty('mimetype'));
+ $this->assertSame('text/plain', $files['empty.txt']->getProperty('mimetype'));
+ $this->assertSame('application/vnd.oasis.opendocument.odt', $files['test2.odt']->getProperty('mimetype'));
+ $this->assertSame('2024-01-10 09:09:09', $files['&kość.odt']->updated_at->toDateTimeString());
+ $file_content = str_repeat('1234567890', 1000);
+ $this->assertSame($file_content, Storage::fileFetch($files['&kość.odt']));
+ $this->assertSame($file_content, Storage::fileFetch($files['test2.odt']));
+ $this->assertSame('', Storage::fileFetch($files['empty.txt']));
+
+ self::$skipTearDown = true;
+ self::$skipSetUp = true;
}
/**
@@ -141,6 +210,10 @@
'/SUMMARY:Party/' => 'SUMMARY:Test'
];
$this->davAppend($src_dav, 'Calendar', ['event/1.ics'], Engine::TYPE_EVENT, $replace);
+ $this->imapEmptyFolder($src_imap, 'Files');
+ $file_content = rtrim(chunk_split(base64_encode('123'), 76, "\r\n"));
+ $replaces = ['/%FILE%/' => $file_content];
+ $this->imapAppend($src_imap, 'Files', 'kolab3/file1.eml', [], '12-Jan-2024 09:09:09 +0000', $replaces);
// Run the migration
$migrator = new Engine();
@@ -180,6 +253,21 @@
$events = \collect($events)->keyBy('uid')->all();
$this->assertCount(2, $events);
$this->assertSame('Test', $events['abcdef']->summary);
+
+ // Assert the migrated files
+ $user = $dst_imap->getUser();
+ $files = $user->fsItems()->whereNot('type', '&', FsItem::TYPE_COLLECTION)
+ ->select('fs_items.*')
+ ->addSelect(DB::raw("(select value from fs_properties where fs_properties.item_id = fs_items.id"
+ . " and fs_properties.key = 'name') as name"))
+ ->get()
+ ->keyBy('name')
+ ->all();
+ $this->assertSame(3, count($files));
+ $this->assertEquals(3, $files['&kość.odt']->getProperty('size'));
+ $this->assertSame('application/vnd.oasis.opendocument.odt', $files['&kość.odt']->getProperty('mimetype'));
+ $this->assertSame('2024-01-12 09:09:09', $files['&kość.odt']->updated_at->toDateTimeString());
+ $this->assertSame('123', Storage::fileFetch($files['&kość.odt']));
}
/**
@@ -195,7 +283,8 @@
}
$kolab3_uri = preg_replace('|^[a-z]+://|', 'kolab3://ned%40kolab.org:simple123@', $imap_uri)
- . '?dav_host=' . preg_replace('|^davs?://|', '', $dav_uri);
+ . '?dav_host=' . preg_replace('|^davs?://|', '', $dav_uri)
+ . '&v4dav=true';
$kolab4_uri = preg_replace('|^[a-z]+://|', 'kolab4://jack%40kolab.org:simple123@', $imap_uri)
. '?dav_host=' . preg_replace('|^davs?://|', '', $dav_uri);
@@ -220,7 +309,6 @@
// Cleanup the account
$this->initAccount($imap_account);
$this->initAccount($dav_account);
- $this->davDeleteFolder($dav_account, 'Custom Calendar', Engine::TYPE_EVENT);
$imap = $this->getImapClient($imap_account);
@@ -235,7 +323,7 @@
// Create a non-mail folder, we'll assert that it was skipped in migration
$this->imapCreateFolder($imap_account, 'Test');
- if (!$imap->setMetadata('Test', ['/private/vendor/kolab/folder-type' => 'contact'])) {
+ if (!$imap->setMetadata('Test', ['/private/vendor/kolab/folder-type' => 'journal'])) {
throw new \Exception("Failed to set metadata");
}
if (!$imap->setMetadata('INBOX', ['/private/vendor/kolab/folder-type' => 'mail.inbox'])) {
@@ -247,14 +335,52 @@
// One more IMAP folder, subscribed
$this->imapCreateFolder($imap_account, 'Test2', true);
-
- // Insert some other data to migrate
+ $imap->setACL('Test2', 'john@kolab.org', 'lrswi');
+
+ // Calendar, Contact, Tasks, Files folders
+ $utf7_folder = \mb_convert_encoding('Files/A€B', 'UTF7-IMAP', 'UTF8');
+ $folders = [
+ 'Calendar' => 'event.default',
+ 'Calendar/Custom Calendar' => 'event',
+ 'Tasks' => 'task',
+ 'Contacts' => 'contact.default',
+ 'Files' => 'file.default',
+ ];
+ $folders[$utf7_folder] = 'file';
+ foreach ($folders as $name => $type) {
+ $this->imapCreateFolder($imap_account, $name);
+ $this->imapEmptyFolder($imap_account, $name);
+ if (!$imap->setMetadata($name, ['/private/vendor/kolab/folder-type' => $type])) {
+ throw new \Exception("Failed to set metadata");
+ }
+ }
+ $imap->setACL('Calendar', 'john@kolab.org', 'lrswi');
+
+ // Insert some files
+ $file_content = str_repeat('1234567890', 1000);
+ $file_content = rtrim(chunk_split(base64_encode($file_content), 76, "\r\n"));
+ $replaces = ['/%FILE%/' => $file_content];
+ $this->imapAppend($imap_account, 'Files', 'kolab3/file1.eml', [], '10-Jan-2024 09:09:09 +0000', $replaces);
+ $this->imapAppend($imap_account, 'Files', 'kolab3/file2.eml', [], '11-Jan-2024 09:09:09 +0000');
+ $replaces['/&amp;kość.odt/'] = 'test2.odt';
+ $replaces['/&ko%C5%9B%C4%87.odt/'] = 'test2.odt';
+ $this->imapAppend($imap_account, $utf7_folder, 'kolab3/file1.eml', [], '12-Jan-2024 09:09:09 +0000', $replaces);
+
+ // Insert some mail to migrate
+ $this->imapEmptyFolder($imap_account, 'INBOX');
+ $this->imapEmptyFolder($imap_account, 'Drafts');
$this->imapAppend($imap_account, 'INBOX', 'mail/1.eml');
$this->imapAppend($imap_account, 'INBOX', 'mail/2.eml', ['SEEN']);
$this->imapAppend($imap_account, 'Drafts', 'mail/3.eml', ['SEEN']);
+
+ // Insert some DAV data to migrate
+ $this->davCreateFolder($dav_account, 'Calendar/Custom Calendar', Engine::TYPE_EVENT);
+ $this->davEmptyFolder($dav_account, 'Calendar', Engine::TYPE_EVENT);
+ $this->davEmptyFolder($dav_account, 'Calendar/Custom Calendar', Engine::TYPE_EVENT);
+ $this->davEmptyFolder($dav_account, 'Contacts', Engine::TYPE_CONTACT);
+ $this->davEmptyFolder($dav_account, 'Tasks', Engine::TYPE_TASK);
$this->davAppend($dav_account, 'Calendar', ['event/1.ics', 'event/2.ics'], Engine::TYPE_EVENT);
- $this->davCreateFolder($dav_account, 'Custom Calendar', Engine::TYPE_EVENT);
- $this->davAppend($dav_account, 'Custom Calendar', ['event/3.ics'], Engine::TYPE_EVENT);
+ $this->davAppend($dav_account, 'Calendar/Custom Calendar', ['event/3.ics'], Engine::TYPE_EVENT);
$this->davAppend($dav_account, 'Contacts', ['contact/1.vcf', 'contact/2.vcf'], Engine::TYPE_CONTACT);
$this->davAppend($dav_account, 'Tasks', ['task/1.ics', 'task/2.ics'], Engine::TYPE_TASK);
}
diff --git a/src/tests/Unit/DataMigrator/Driver/Kolab/FilesTest.php b/src/tests/Unit/DataMigrator/Driver/Kolab/FilesTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/DataMigrator/Driver/Kolab/FilesTest.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Tests\Unit\DataMigrator\Driver\Kolab;
+
+use App\DataMigrator\Driver\Kolab\Files;
+use Tests\TestCase;
+
+class FilesTest extends TestCase
+{
+ /**
+ * Test attachment part headers processing
+ */
+ public function testParseHeaders(): void
+ {
+ $files = new Files();
+
+ $headers = "Content-ID: <ko.1732537341.48230.odt>\r\n"
+ . "Content-Transfer-Encoding: base64\r\n"
+ . "Content-Type: application/vnd.oasis.opendocument.odt;\r\n"
+ . " name*=UTF-8''&ko%C5%9B%C4%87.txt\r\n"
+ . "Content-Disposition: attachment;\r\n"
+ . " filename*=UTF-8''&ko%C5%9B%C4%87.odt;\r\n"
+ . " size=30068\r\n";
+
+ $result = $this->invokeMethod($files, 'parseHeaders', [$headers]);
+ $this->assertSame('application/vnd.oasis.opendocument.odt', $result[0]);
+ $this->assertSame('&kość.odt', $result[1]);
+ $this->assertSame(30068, $result[2]);
+ $this->assertSame('base64', $result[3]);
+
+ $headers = "Content-ID: <ko.1732537341.48230.odt>\r\n"
+ . "Content-Transfer-Encoding: quoted-printable\r\n"
+ . "Content-Type: text/plain;\r\n"
+ . " name*0*=UTF-8''a%20very%20long%20name%20for%20the%20attachment%20to%20tes;\r\n"
+ . " name*1*=t%20%C4%87%C4%87%C4%87%20will%20see%20how%20it%20goes%20with%20so;\r\n"
+ . " name*2*=me%20non-ascii%20ko%C5%9B%C4%87%20characters.txt\r\n"
+ . "Content-Disposition: attachment;\r\n"
+ . " filename*0*=UTF-8''a%20very%20long%20name%20for%20the%20attachment%20to;\r\n"
+ . " filename*1*=%20test%20%C4%87%C4%87%C4%87%20will%20see%20how%20it%20goes;\r\n"
+ . " filename*2*=%20with%20some%20non-ascii%20ko%C5%9B%C4%87%20characters.txt\r\n";
+
+ $result = $this->invokeMethod($files, 'parseHeaders', [$headers]);
+ $this->assertSame('text/plain', $result[0]);
+ $this->assertSame('a very long name for the attachment to test ććć will see how '
+ . 'it goes with some non-ascii kość characters.txt', $result[1]);
+ $this->assertSame(null, $result[2]);
+ $this->assertSame('quoted-printable', $result[3]);
+
+ $headers = "Content-Type: text/plain; name=test.txt\r\n"
+ . "Content-Disposition: attachment; filename=test.txt\r\n";
+
+ $result = $this->invokeMethod($files, 'parseHeaders', [$headers]);
+ $this->assertSame('text/plain', $result[0]);
+ $this->assertSame('test.txt', $result[1]);
+ $this->assertSame(null, $result[2]);
+ $this->assertSame(null, $result[3]);
+ }
+}
diff --git a/src/tests/data/kolab3/file1.eml b/src/tests/data/kolab3/file1.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/kolab3/file1.eml
@@ -0,0 +1,56 @@
+MIME-Version: 1.0
+From: ned@kolab.org
+To: ned@kolab.org
+Date: Mon, 25 Nov 2024 13:22:21 +0100
+X-Kolab-Type: application/x-vnd.kolab.file
+X-Kolab-Mime-Version: 3.0
+Subject: f76382d6-651d-43ea-ae64-eca3884aed04
+User-Agent: Kolab 16/Roundcube 1.5-git
+Content-Type: multipart/mixed;
+ boundary="=_f70c0dafff53b07d8e6e5a83f1430da1"
+
+--=_f70c0dafff53b07d8e6e5a83f1430da1
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=ISO-8859-1
+
+This is a Kolab Groupware object. To view this object you will need an emai=
+l client that understands the Kolab Groupware format. For a list of such em=
+ail clients please visit https://www.kolab.org/
+
+
+--=_f70c0dafff53b07d8e6e5a83f1430da1
+Content-Transfer-Encoding: 8bit
+Content-Type: application/vnd.kolab+xml; charset=UTF-8;
+ name=kolab.xml
+Content-Disposition: attachment;
+ filename=kolab.xml;
+ size=618
+
+<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
+<file xmlns="http://kolab.org" version="3.0">
+ <uid>f76382d6-651d-43ea-ae64-eca3884aed04</uid>
+ <prodid>Roundcube-libkolab-1.1 Libkolabxml-1.3.1</prodid>
+ <creation-date>2019-03-12T09:35:08Z</creation-date>
+ <last-modification-date>2024-11-25T12:22:21Z</last-modification-date>
+ <classification>PUBLIC</classification>
+ <file>
+ <parameters>
+ <fmttype>application/vnd.oasis.opendocument.text</fmttype>
+ <x-label>&amp;kość.odt</x-label>
+ </parameters>
+ <uri>cid:ko.1732537341.48230.odt</uri>
+ </file>
+ <note/>
+</file>
+
+--=_f70c0dafff53b07d8e6e5a83f1430da1
+Content-ID: <ko.1732537341.48230.odt>
+Content-Transfer-Encoding: base64
+Content-Type: application/vnd.oasis.opendocument.odt;
+ name*=UTF-8''&ko%C5%9B%C4%87.odt
+Content-Disposition: attachment;
+ filename*=UTF-8''&ko%C5%9B%C4%87.odt;
+ size=30068
+
+%FILE%
+--=_f70c0dafff53b07d8e6e5a83f1430da1--
diff --git a/src/tests/data/kolab3/file2.eml b/src/tests/data/kolab3/file2.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/kolab3/file2.eml
@@ -0,0 +1,52 @@
+MIME-Version: 1.0
+From: ned@kolab.org
+To: ned@kolab.org
+Date: Mon, 25 Nov 2024 13:22:21 +0100
+X-Kolab-Type: application/x-vnd.kolab.file
+X-Kolab-Mime-Version: 3.0
+Subject: f76382d6-651d-43ea-ae64-eca3884aed05
+User-Agent: Kolab 16/Roundcube 1.5-git
+Content-Type: multipart/mixed;
+ boundary="=_f70c0dafff53b07d8e6e5a83f1430da1"
+
+--=_f70c0dafff53b07d8e6e5a83f1430da1
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=ISO-8859-1
+
+This is a Kolab Groupware object. To view this object you will need an emai=
+l client that understands the Kolab Groupware format. For a list of such em=
+ail clients please visit https://www.kolab.org/
+
+
+--=_f70c0dafff53b07d8e6e5a83f1430da1
+Content-Transfer-Encoding: 8bit
+Content-Type: application/vnd.kolab+xml; charset=UTF-8;
+ name=kolab.xml
+Content-Disposition: attachment;
+ filename=kolab.xml
+
+<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
+<file xmlns="http://kolab.org" version="3.0">
+ <uid>f76382d6-651d-43ea-ae64-eca3884aed05</uid>
+ <prodid>Roundcube-libkolab-1.1 Libkolabxml-1.3.1</prodid>
+ <creation-date>2024-03-12T09:35:08Z</creation-date>
+ <last-modification-date>2024-11-27T12:22:21Z</last-modification-date>
+ <classification>PUBLIC</classification>
+ <file>
+ <parameters>
+ <fmttype>text/plain</fmttype>
+ <x-label>empty.txt</x-label>
+ </parameters>
+ <uri>cid:empty.1732537341.48230.txt</uri>
+ </file>
+ <note/>
+</file>
+
+--=_f70c0dafff53b07d8e6e5a83f1430da1
+Content-ID: <empty.1732537341.48230.txt>
+Content-Transfer-Encoding: base64
+Content-Type: text/plain; name=empty.txt
+Content-Disposition: attachment; filename=empty.txt; size=0
+
+
+--=_f70c0dafff53b07d8e6e5a83f1430da1--

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 6:26 AM (13 h, 46 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18828373
Default Alt Text
D5092.1775284003.diff (59 KB)

Event Timeline