Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117846084
D5092.1775311434.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
35 KB
Referenced Files
None
Subscribers
None
D5092.1775311434.diff
View Options
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
*
@@ -137,15 +158,27 @@
$file->save();
}
+ $properties = [
+ 'size' => $fileSize,
+ // Pick the client-supplied mimetype if available, otherwise detect.
+ 'mimetype' => !empty($params['mimetype']) ? $params['mimetype'] : self::mimetype($path),
+ ];
+
// Update the file type and size information
- $file->setProperties([
- 'size' => $fileSize,
- // Pick the client-supplied mimetype if available, otherwise detect.
- 'mimetype' => !empty($params['mimetype']) ? $params['mimetype'] : self::mimetype($path),
- ]);
+ // We can optimize this if we just created a new file and we know there's no property records yet.
+ if (!empty($params['isNew'])) {
+ foreach ($properties as $key => &$value) {
+ $value = ['key' => $key, 'value' => $value];
+ }
+ $file->properties()->createMany($properties);
+ } else {
+ $file->setProperties($properties);
- // Assign the node to the file, "unlink" any old nodes of this file
- $file->chunks()->delete();
+ // "Unlink" any old nodes of this file
+ $file->chunks()->delete();
+ }
+
+ // Assign the node to the file
$file->chunks()->create([
'chunk_id' => $chunkId,
'sequence' => 0,
@@ -212,7 +245,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/Kolab.php b/src/app/DataMigrator/Driver/Kolab.php
--- a/src/app/DataMigrator/Driver/Kolab.php
+++ b/src/app/DataMigrator/Driver/Kolab.php
@@ -21,6 +21,11 @@
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 +92,12 @@
$this->davDriver->createFolder($folder);
return;
}
+
+ // Files
+ if ($folder->type == Engine::TYPE_FILE) {
+ Kolab\Files::createFolder($this->account, $folder);
+ return;
+ }
}
/**
@@ -115,6 +126,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);
@@ -144,6 +161,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 +196,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
@@ -204,10 +240,11 @@
// Get DAV folders
if (empty($types) || count(array_intersect($types, self::DAV_TYPES)) > 0) {
+ $types = array_intersect($types, self::DAV_TYPES);
$result = $this->davDriver->getFolders($types);
}
- if (!empty($types) && count(array_intersect($types, [Engine::TYPE_MAIL, Engine::TYPE_CONFIGURATION])) == 0) {
+ if (!empty($types) && count(array_intersect($types, self::IMAP_TYPES)) == 0) {
return $result;
}
@@ -237,7 +274,7 @@
[$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)) {
continue;
}
@@ -297,7 +334,12 @@
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);
}
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");
+ }
+ }
+
+ $result = $imap->handlePartBody($item->data['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 {$item->data['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]);
+ $params['isNew'] = true;
+ }
+
+ 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/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,9 @@
/** @var bool Folder subscription state */
public $subscribed = true;
+ /** @var array Extra data to migrate (like ACL) or some temporary 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
*/
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 = []): string
{
$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();
}
@@ -117,6 +139,45 @@
$this->assertCount(2, $tasks);
$this->assertSame('Task1', $tasks['ccc-ccc']->summary);
$this->assertSame('Task2', $tasks['ddd-ddd']->summary);
+
+ // 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 +202,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 +245,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']));
}
/**
@@ -248,10 +328,33 @@
// One more IMAP folder, subscribed
$this->imapCreateFolder($imap_account, 'Test2', true);
- // Insert some other data to migrate
+ // File storage in IMAP
+ $utf7_folder = \mb_convert_encoding('Files/A€B', 'UTF7-IMAP', 'UTF8');
+ $this->imapCreateFolder($imap_account, 'Files');
+ $this->imapCreateFolder($imap_account, $utf7_folder);
+ $this->imapEmptyFolder($imap_account, 'Files');
+ $this->imapEmptyFolder($imap_account, $utf7_folder);
+ if (!$imap->setMetadata('Files', ['/private/vendor/kolab/folder-type' => 'file.default'])) {
+ throw new \Exception("Failed to set metadata");
+ }
+ if (!$imap->setMetadata($utf7_folder, ['/private/vendor/kolab/folder-type' => 'file'])) {
+ throw new \Exception("Failed to set metadata");
+ }
+ $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['/&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->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->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);
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>&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
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 2:03 PM (14 h, 36 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18829787
Default Alt Text
D5092.1775311434.diff (35 KB)
Attached To
Mode
D5092: Files migration from Kolab v3 to Kolab v4
Attached
Detach File
Event Timeline