diff --git a/src/app/Backends/DAV/Vcard.php b/src/app/Backends/DAV/Vcard.php index d846e1dc..19161703 100644 --- a/src/app/Backends/DAV/Vcard.php +++ b/src/app/Backends/DAV/Vcard.php @@ -1,136 +1,135 @@ getElementsByTagName('address-data')->item(0)) { $object->fromVcard($data->nodeValue); } return $object; } /** * Set object properties from a vcard * * @param string $vcard vCard string */ protected function fromVcard(string $vcard): void { $vobject = Reader::read($vcard, Reader::OPTION_FORGIVING | Reader::OPTION_IGNORE_INVALID_LINES); if ($vobject->name != 'VCARD') { // FIXME: throw an exception? return; } $this->vobject = $vobject; - $this->members = []; $string_properties = [ 'CLASS', 'FN', 'KIND', 'NOTE', 'PRODID', 'REV', 'UID', 'VERSION', ]; foreach ($vobject->children() as $prop) { if (!($prop instanceof Property)) { continue; } switch ($prop->name) { // TODO: Map all vCard properties to class properties case 'EMAIL': $props = []; foreach ($prop->parameters() as $name => $value) { $key = Str::camel(strtolower($name)); $props[$key] = (string) $value; } $props['email'] = (string) $prop; $this->email[] = $props; break; case 'MEMBER': foreach ($prop as $member) { $value = (string) $member; - if (preg_match('/^mailto:/', $value)) { - $this->members[] = $value; + if (preg_match('/^(urn:uuid|mailto):/i', $value)) { + $this->member[] = $value; } } break; default: // map string properties if (in_array($prop->name, $string_properties)) { $key = Str::camel(strtolower($prop->name)); $this->{$key} = (string) $prop; } // custom properties if (\str_starts_with($prop->name, 'X-')) { $this->custom[$prop->name] = (string) $prop; } } } if (!empty($this->custom['X-ADDRESSBOOKSERVER-KIND']) && empty($this->kind)) { $this->kind = strtolower($this->custom['X-ADDRESSBOOKSERVER-KIND']); } } /** * Create string representation of the DAV object (vcard) * * @return string */ public function __toString() { if (!$this->vobject) { // TODO we currently can only serialize a message back that we just read throw new \Exception("Writing from properties is not implemented"); } return Writer::write($this->vobject); } } diff --git a/src/app/Http/Controllers/API/V4/SearchController.php b/src/app/Http/Controllers/API/V4/SearchController.php index 4f8f208b..5b4a5021 100644 --- a/src/app/Http/Controllers/API/V4/SearchController.php +++ b/src/app/Http/Controllers/API/V4/SearchController.php @@ -1,162 +1,162 @@ guard()->user(); $search = trim(request()->input('search')); $with_aliases = !empty(request()->input('alias')); $limit = intval(request()->input('limit')); if ($limit <= 0) { $limit = 15; } elseif ($limit > 100) { $limit = 100; } // Prepare the query $query = User::select('email', 'id')->where('id', $user->id); $aliases = DB::table('user_aliases')->select(DB::raw('alias as email, user_id as id')) ->where('user_id', $user->id); if (strlen($search)) { $aliases->whereLike('alias', $search); $query->whereLike('email', $search); } if ($with_aliases) { $query->union($aliases); } // Execute the query $result = $query->orderBy('email')->limit($limit)->get(); $result = $this->resultFormat($result); return response()->json([ 'list' => $result, 'count' => count($result), ]); } /** * Search request for addresses of all users (in an account) * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function searchUser(Request $request) { $user = $this->guard()->user(); $search = trim(request()->input('search')); $with_aliases = !empty(request()->input('alias')); $limit = intval(request()->input('limit')); if ($limit <= 0) { $limit = 15; } elseif ($limit > 100) { $limit = 100; } $wallet = $user->wallet(); // Limit users to the user's account $allUsers = $wallet->entitlements() ->where('entitleable_type', User::class) ->select('entitleable_id') ->distinct(); // Sub-query for user IDs who's names match the search criteria $foundUserIds = UserSetting::select('user_id') ->whereIn('key', ['first_name', 'last_name']) ->whereLike('value', $search) ->whereIn('user_id', $allUsers); // Prepare the query $query = User::select('email', 'id')->whereIn('id', $allUsers); $aliases = DB::table('user_aliases')->select(DB::raw('alias as email, user_id as id')) ->whereIn('user_id', $allUsers); if (strlen($search)) { $query->where(function ($query) use ($foundUserIds, $search) { $query->whereLike('email', $search) ->orWhereIn('id', $foundUserIds); }); $aliases->where(function ($query) use ($foundUserIds, $search) { $query->whereLike('alias', $search) ->orWhereIn('user_id', $foundUserIds); }); } if ($with_aliases) { $query->union($aliases); } // Execute the query $result = $query->orderBy('email')->limit($limit)->get(); $result = $this->resultFormat($result); return response()->json([ 'list' => $result, 'count' => count($result), ]); } /** * Format the search result, inject user names */ protected function resultFormat($result) { if ($result->count()) { // Get user names $settings = UserSetting::whereIn('key', ['first_name', 'last_name']) ->whereIn('user_id', $result->pluck('id')) ->get() ->mapWithKeys(function ($item) { return [($item->user_id . ':' . $item->key) => $item->value]; }) ->all(); // "Format" the result, include user names $result = $result->map(function ($record) use ($settings) { return [ 'email' => $record->email, 'name' => trim( ($settings["{$record->id}:first_name"] ?? '') . ' ' . ($settings["{$record->id}:last_name"] ?? '') ), ]; - }) + }) ->sortBy(['name', 'email']) ->values(); } return $result; } } diff --git a/src/tests/Feature/Console/Scalpel/Domain/CreateCommandTest.php b/src/tests/Feature/Console/Scalpel/Domain/CreateCommandTest.php index dbe03d99..f949cf74 100644 --- a/src/tests/Feature/Console/Scalpel/Domain/CreateCommandTest.php +++ b/src/tests/Feature/Console/Scalpel/Domain/CreateCommandTest.php @@ -1,64 +1,65 @@ deleteTestDomain('domain-delete.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestDomain('domain-delete.com'); parent::tearDown(); } /** * Test the command execution */ public function testHandle(): void { // Test --help argument $code = \Artisan::call("scalpel:domain:create --help"); $output = trim(\Artisan::output()); $this->assertSame(0, $code); $this->assertStringContainsString('--namespace[=NAMESPACE]', $output); $this->assertStringContainsString('--type[=TYPE]', $output); $this->assertStringContainsString('--status[=STATUS]', $output); $this->assertStringContainsString('--tenant_id[=TENANT_ID]', $output); $tenant = \App\Tenant::orderBy('id', 'desc')->first(); // Test successful domain creation - $code = \Artisan::call("scalpel:domain:create" + $code = \Artisan::call( + "scalpel:domain:create" . " --namespace=domain-delete.com" - . " --type=" . Domain::TYPE_PUBLIC + . " --type={Domain::TYPE_PUBLIC}" . " --tenant_id={$tenant->id}" ); $output = trim(\Artisan::output()); $domain = $this->getTestDomain('domain-delete.com'); $this->assertSame(0, $code); $this->assertSame($output, (string) $domain->id); $this->assertSame('domain-delete.com', $domain->namespace); $this->assertSame(Domain::TYPE_PUBLIC, $domain->type); $this->assertSame($domain->tenant_id, $tenant->id); } } diff --git a/src/tests/Unit/DataMigrator/EWS/AppointmentTest.php b/src/tests/Unit/DataMigrator/EWS/AppointmentTest.php index 4ee95f26..8e11b99b 100644 --- a/src/tests/Unit/DataMigrator/EWS/AppointmentTest.php +++ b/src/tests/Unit/DataMigrator/EWS/AppointmentTest.php @@ -1,112 +1,113 @@ 'test']); $appointment = new EWS\Appointment($ews, $folder); $ical = file_get_contents(__DIR__ . '/../../../data/ews/event/1.ics'); + $ical = preg_replace('/\r?\n/', "\r\n", $ical); // FIXME: I haven't found a way to convert xml content into a Type instance // therefore we create it "manually", but it would be better to have both // vcard and xml in a single data file that we could just get content from. $item = Type::buildFromArray([ 'MimeContent' => base64_encode($ical), 'ItemId' => new Type\ItemIdType( 'AAMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NGQ0OABGAAAAAAC9tlDYSlG2TaxWBr' . 'A1OzWtBwAs2ajhknXlRYN/pbC8JqblAAAAAAEOAAAs2ajhknXlRYN/pbC8JqblAAJnrWkBAAA=', 'EQAAABYAAAAs2ajhknXlRYN/pbC8JqblAAJnqlKm', ), 'UID' => '1F3C13D7E99642A75ABE23D50487B454-8FE68B2E68E1B348', 'Subject' => 'test subject', 'HasAttachments' => false, 'IsAssociated' => false, 'Start' => '2023-11-21T11:00:00Z', 'End' => '2023-11-21T11:30:00Z', 'LegacyFreeBusyStatus' => 'Tentative', 'CalendarItemType' => 'Single', 'Organizer' => [ 'Mailbox' => [ 'Name' => 'Aleksander Machniak', 'EmailAddress' => 'test@kolab.org', 'RoutingType' => 'SMTP', 'MailboxType' => 'Contact', ], ], 'RequiredAttendees' => (object) [ 'Attendee' => [ Type\AttendeeType::buildFromArray([ 'Mailbox' => [ 'Name' => 'Aleksander Machniak', 'EmailAddress' => 'test@kolab.org', 'RoutingType' => 'SMTP', 'MailboxType' => 'Contact', ], 'ResponseType' => 'Unknown', ]), Type\AttendeeType::buildFromArray([ 'Mailbox' => [ 'Name' => 'Alec Machniak', 'EmailAddress' => 'test@outlook.com', 'RoutingType' => 'SMTP', 'MailboxType' => 'Mailbox', ], 'ResponseType' => 'Unknown', ]), ], ], ]); // Convert the Exchange item into iCalendar $ical = $this->invokeMethod($appointment, 'processItem', [$item]); // Parse the iCalendar output $event = new Vevent(); $this->invokeMethod($event, 'fromIcal', [$ical]); $msId = implode('!', $item->getItemId()->toArray()); $this->assertSame($msId, $event->custom['X-MS-ID']); $this->assertSame($item->getUID(), $event->uid); $this->assertSame('test description', $event->description); $this->assertSame('test subject', $event->summary); $this->assertSame('CONFIRMED', $event->status); $this->assertSame('PUBLIC', $event->class); $this->assertSame('Microsoft Exchange Server 2010', $event->prodid); $this->assertSame('2023-11-20T14:50:05+00:00', $event->dtstamp->getDateTime()->format('c')); $this->assertSame('2023-11-21T12:00:00+01:00', $event->dtstart->getDateTime()->format('c')); $this->assertSame('2023-11-21T12:30:00+01:00', $event->dtend->getDateTime()->format('c')); // Organizer/attendees $this->assertSame('test@kolab.org', $event->organizer['email']); $this->assertSame('Aleksander Machniak', $event->organizer['cn']); $this->assertSame('ORGANIZER', $event->organizer['role']); $this->assertSame('ACCEPTED', $event->organizer['partstat']); $this->assertSame(false, $event->organizer['rsvp']); $this->assertCount(1, $event->attendees); $this->assertSame('alec@outlook.com', $event->attendees[0]['email']); $this->assertSame('Alec Machniak', $event->attendees[0]['cn']); $this->assertSame('REQ-PARTICIPANT', $event->attendees[0]['role']); $this->assertSame('NEEDS-ACTION', $event->attendees[0]['partstat']); $this->assertSame(true, $event->attendees[0]['rsvp']); } } diff --git a/src/tests/Unit/DataMigrator/EWS/ContactTest.php b/src/tests/Unit/DataMigrator/EWS/ContactTest.php index 2aaa9a01..87a4be69 100644 --- a/src/tests/Unit/DataMigrator/EWS/ContactTest.php +++ b/src/tests/Unit/DataMigrator/EWS/ContactTest.php @@ -1,140 +1,141 @@ 'test']); $contact = new EWS\Contact($ews, $folder); $vcard = file_get_contents(__DIR__ . '/../../../data/ews/contact/1.vcf'); + $vcard = preg_replace('/\r?\n/', "\r\n", $vcard); // FIXME: I haven't found a way to convert xml content into a Type instance // therefore we create it "manually", but it would be better to have both // vcard and xml in a single data file that we could just get content from. $item = Type::buildFromArray([ 'MimeContent' => base64_encode($vcard), 'ItemId' => new Type\ItemIdType( 'AAMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NGQ0OABGAAAAAAC9tlDYSlG2TaxWBr' . 'A1OzWtBwAs2ajhknXlRYN/pbC8JqblAAAAAAEOAAAs2ajhknXlRYN/pbC8JqblAAJnrWkBAAA=', 'EQAAABYAAAAs2ajhknXlRYN/pbC8JqblAAJnqlKm', ), 'HasAttachments' => false, 'LastModifiedTime' => '2024-07-15T11:17:39,701Z', 'DisplayName' => 'Nowy Nazwisko', 'GivenName' => 'Nowy', 'Surname' => 'Nazwisko', 'EmailAddresses' => (object) [ 'Entry' => [ Type\EmailAddressDictionaryEntryType::buildFromArray([ 'Key' => 'EmailAddress1', 'Name' => 'test1@outlook.com', 'RoutingType' => 'SMTP', 'MailboxType' => 'Contact', '_value' => 'christian1@outlook.com', ]), Type\EmailAddressDictionaryEntryType::buildFromArray([ 'Key' => 'EmailAddress2', 'Name' => 'test2@outlook.com', 'RoutingType' => 'SMTP', 'MailboxType' => 'Contact', '_value' => 'test2@outlook.com', ]), ], ], /* ContactPicture.jpg image/jpeg 2081 2024-07-15T11:17:38 false true category en-US Mr Nowy Bartosz Nazwisko Jr. Nowy Nazwisko alec Company Testowa Warsaw mazowickie Poland 00-001 home123456 1234556679200 2014-10-11T11:59:00Z IT Developer Office Location 2020-11-12T11:59:00Z true */ ]); // Convert the Exchange item into vCard $vcard = $this->invokeMethod($contact, 'processItem', [$item]); // Parse the vCard $contact = new Vcard(); $this->invokeMethod($contact, 'fromVcard', [$vcard]); $this->assertMatchesRegularExpression('/^[a-f0-9]{40}$/', $contact->uid); $this->assertSame('PUBLIC', $contact->class); $this->assertSame('Nowy Nazwisko', $contact->fn); $this->assertSame(null, $contact->kind); $this->assertSame('Microsoft Exchange', $contact->prodid); $this->assertSame('2024-07-15T11:17:39,701Z', $contact->rev); $this->assertSame('Notatki do kontaktu', $contact->note); // EWS Properties with special handling $msId = implode('!', $item->getItemId()->toArray()); $this->assertSame($msId, $contact->custom['X-MS-ID']); $this->assertSame('Partner Name', $contact->custom['X-SPOUSE']); $this->assertSame('2020-11-12', $contact->custom['X-ANNIVERSARY']); $this->assertCount(2, $contact->email); $this->assertSame('internet', $contact->email[0]['type']); $this->assertSame('christian1@outlook.com', $contact->email[0]['email']); $this->assertSame('internet', $contact->email[1]['type']); $this->assertSame('test2@outlook.com', $contact->email[1]['email']); } } diff --git a/src/tests/Unit/DataMigrator/EWS/DistListTest.php b/src/tests/Unit/DataMigrator/EWS/DistListTest.php index 528bfda7..e9e198a9 100644 --- a/src/tests/Unit/DataMigrator/EWS/DistListTest.php +++ b/src/tests/Unit/DataMigrator/EWS/DistListTest.php @@ -1,90 +1,90 @@ 'test']); $distlist = new EWS\DistList($ews, $folder); // FIXME: I haven't found a way to convert xml content into a Type instance // therefore we create it "manually", but it would be better to have both // vcard and xml in a single data file that we could just get content from. $item = Type::buildFromArray([ 'ItemId' => new Type\ItemIdType( 'AAMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NGQ0OABGAAAAAAC9tlDYSlG2TaxWBr' . 'A1OzWtBwAs2ajhknXlRYN/pbC8JqblAAAAAAEOAAAs2ajhknXlRYN/pbC8JqblAAJnrWkBAAA=', 'EQAAABYAAAAs2ajhknXlRYN/pbC8JqblAAJnqlKm', ), 'Subject' => 'subject list', 'LastModifiedTime' => '2024-06-27T13:44:32Z', 'DisplayName' => 'Lista', 'FileAs' => 'lista', 'Members' => (object) [ 'Member' => [ Type\MemberType::buildFromArray([ 'Key' => 'AAAAAIErH6S+oxAZnW4A3QEPVAIAAAGAYQBsAGUAYwBAAGEAbABlAGMALgBw' . 'AGwAAABTAE0AVABQAAAAYQBsAGUAYwBAAGEAbABlAGMALgBwAGwAAAA=', 'Mailbox' => Type\Mailbox::buildFromArray([ 'Name' => 'Alec', 'EmailAddress' => 'alec@kolab.org', 'RoutingType' => 'SMTP', 'MailboxType' => 'OneOff', ]), 'Status' => 'Normal', ]), Type\MemberType::buildFromArray([ 'Key' => 'AAAAAIErH6S+oxAZnW4A3QEPVAIAAAGAYQBsAGUAYwBAAGEAbABlAGMALgBw' . 'AGwAAABTAE0AVABQAAAAYQBsAGUAYwBAAGEAbABlAGMALgBwAGwAAAB=', 'Mailbox' => Type\Mailbox::buildFromArray([ 'Name' => 'Christian', 'EmailAddress' => 'christian@kolab.org', 'RoutingType' => 'SMTP', 'MailboxType' => 'OneOff', 'ItemId' => new Type\ItemIdType('AAA', 'BBB'), ]), 'Status' => 'Normal', ]), ], ], ]); // Convert the Exchange item into vCard $vcard = $this->invokeMethod($distlist, 'processItem', [$item]); // Parse the vCard $distlist = new Vcard(); $this->invokeMethod($distlist, 'fromVcard', [$vcard]); $msId = implode('!', $item->getItemId()->toArray()); $this->assertSame(['X-MS-ID' => $msId], $distlist->custom); $this->assertMatchesRegularExpression('/^[a-f0-9]{40}$/', $distlist->uid); $this->assertSame('group', $distlist->kind); $this->assertSame('Lista', $distlist->fn); $this->assertSame('Kolab EWS Data Migrator', $distlist->prodid); $this->assertSame('2024-06-27T13:44:32Z', $distlist->rev); $members = [ 'mailto:%22Alec%22+%3Calec%40kolab.org%3E', 'urn:uuid:' . sha1('AAA'), ]; - $this->assertSame($members, $distlist->members); + $this->assertSame($members, $distlist->member); } }