diff --git a/lib/Controller/CallController.php b/lib/Controller/CallController.php index 277940b7678..b36d89d9381 100644 --- a/lib/Controller/CallController.php +++ b/lib/Controller/CallController.php @@ -157,7 +157,7 @@ public function downloadParticipantsForCall(string $format = 'csv'): DataDownloa 'email', 'type', 'identifier', - ]); + ], escape: ''); foreach ($participants as $participant) { $email = ''; @@ -171,7 +171,7 @@ public function downloadParticipantsForCall(string $format = 'csv'): DataDownloa $email, $participant->getAttendee()->getActorType(), $participant->getAttendee()->getActorId(), - ]); + ], escape: ''); } fseek($output, 0); diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index 1d3ca673b1e..9cd17672661 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -2432,7 +2432,7 @@ public function setMessageExpiration(int $seconds): DataResponse { * * Content format is comma separated values: * - Header line is required and must match `"email","name"` or `"email"` - * - one entry per line + * - One entry per line (e.g. `"John Doe","john@example.tld"`) * * Required capability: `email-csv-import` * @@ -2461,7 +2461,7 @@ public function importEmailsAsParticipants(bool $testRun = false): DataResponse } try { - $data = $this->guestManager->importEmails($this->room, $file, $testRun); + $data = $this->guestManager->importEmails($this->room, $file['tmp_name'], $testRun); return new DataResponse($data); } catch (GuestImportException $e) { return new DataResponse($e->getData(), Http::STATUS_BAD_REQUEST); diff --git a/lib/Exceptions/GuestImportException.php b/lib/Exceptions/GuestImportException.php index e5c780c05a6..a274efc9bb6 100644 --- a/lib/Exceptions/GuestImportException.php +++ b/lib/Exceptions/GuestImportException.php @@ -28,7 +28,7 @@ public function __construct( protected readonly ?int $invites = null, protected readonly ?int $duplicates = null, ) { - parent::__construct(); + parent::__construct($reason); } /** diff --git a/lib/GuestManager.php b/lib/GuestManager.php index c0d89951c45..4446affdd16 100644 --- a/lib/GuestManager.php +++ b/lib/GuestManager.php @@ -84,7 +84,7 @@ public function validateMailAddress(string $email): bool { * @return array{invites: non-negative-int, duplicates: non-negative-int, invalid?: non-negative-int, invalidLines?: list, type?: int<-1, 6>} * @throws GuestImportException */ - public function importEmails(Room $room, $file, bool $testRun): array { + public function importEmails(Room $room, string $filePath, bool $testRun): array { if ($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER || $room->getType() === Room::TYPE_NOTE_TO_SELF @@ -93,43 +93,55 @@ public function importEmails(Room $room, $file, bool $testRun): array { throw new GuestImportException(GuestImportException::REASON_ROOM); } - $content = fopen($file['tmp_name'], 'rb'); + $content = fopen($filePath, 'rb'); $details = fgetcsv($content, escape: ''); - if (!isset($details[0]) || strtolower($details[0]) !== 'email') { + + $emailKey = $nameKey = null; + foreach ($details as $key => $header) { + if (strtolower($header) === 'email') { + $emailKey = $key; + } elseif (strtolower($header) === 'name') { + $nameKey = $key; + } + } + + if ($emailKey === null) { throw new GuestImportException( GuestImportException::REASON_HEADER_EMAIL, $this->l->t('Missing email field in header line'), ); } - if (isset($details[1]) && strtolower($details[1]) !== 'name') { - throw new GuestImportException( - GuestImportException::REASON_HEADER_NAME, - $this->l->t('Missing name field in header line'), - ); + + if ($nameKey === null) { + $this->logger->debug('No name field in header line, skipping name import'); } $participants = $this->participantService->getParticipantsByActorType($room, Attendee::ACTOR_EMAILS); $alreadyInvitedEmails = array_flip(array_map(static fn (Participant $participant): string => $participant->getAttendee()->getInvitedCloudId(), $participants)); - $line = $duplicates = 0; + $line = 1; + $duplicates = 0; $emailsToAdd = $invalidLines = []; while ($details = fgetcsv($content, escape: '')) { $line++; - if (isset($alreadyInvitedEmails[$details[0]])) { - $this->logger->debug('Skipping import of ' . $details[0] . ' (line: ' . $line . ') as they are already invited'); + if (isset($alreadyInvitedEmails[$details[$emailKey]])) { + $this->logger->debug('Skipping import of ' . $details[$emailKey] . ' (line: ' . $line . ') as they are already invited'); $duplicates++; continue; } - if (count($details) > 2) { - $this->logger->debug('Invalid entry with too many fields on line: ' . $line); + if (!isset($details[$emailKey])) { + $this->logger->debug('Invalid entry without email fields on line: ' . $line); $invalidLines[] = $line; continue; } - $email = strtolower(trim($details[0])); - if (count($details) === 2) { - $name = trim($details[1]); + $email = strtolower(trim($details[$emailKey])); + if ($nameKey !== null && isset($details[$nameKey])) { + $name = trim($details[$nameKey]); + if ($name === '' || strcasecmp($name, $email) === 0) { + $name = null; + } } else { $name = null; } @@ -140,10 +152,6 @@ public function importEmails(Room $room, $file, bool $testRun): array { continue; } - if ($name !== null && strcasecmp($name, $email) === 0) { - $name = null; - } - $actorId = hash('sha256', $email); $alreadyInvitedEmails[$email] = $actorId; $emailsToAdd[] = [ diff --git a/openapi-full.json b/openapi-full.json index 4df58c94912..c66cefb0c9f 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -16526,7 +16526,7 @@ "post": { "operationId": "room-import-emails-as-participants", "summary": "Import a list of email attendees", - "description": "Content format is comma separated values: - Header line is required and must match `\"email\",\"name\"` or `\"email\"` - one entry per line\nRequired capability: `email-csv-import`", + "description": "Content format is comma separated values: - Header line is required and must match `\"email\",\"name\"` or `\"email\"` - One entry per line (e.g. `\"John Doe\",\"john@example.tld\"`)\nRequired capability: `email-csv-import`", "tags": [ "room" ], diff --git a/openapi.json b/openapi.json index 822d655d030..10c7d8a8382 100644 --- a/openapi.json +++ b/openapi.json @@ -16660,7 +16660,7 @@ "post": { "operationId": "room-import-emails-as-participants", "summary": "Import a list of email attendees", - "description": "Content format is comma separated values: - Header line is required and must match `\"email\",\"name\"` or `\"email\"` - one entry per line\nRequired capability: `email-csv-import`", + "description": "Content format is comma separated values: - Header line is required and must match `\"email\",\"name\"` or `\"email\"` - One entry per line (e.g. `\"John Doe\",\"john@example.tld\"`)\nRequired capability: `email-csv-import`", "tags": [ "room" ], diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index b6b4558d0c7..c91d9c63e60 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -87,6 +87,8 @@ export const mockedCapabilities: Capabilities = { 'archived-conversations', 'talk-polls-drafts', 'archived-conversations-v2', + 'download-call-participants', + 'email-csv-import', ], 'features-local': [ 'favorites', @@ -100,6 +102,7 @@ export const mockedCapabilities: Capabilities = { 'note-to-self', 'archived-conversations', 'archived-conversations-v2', + 'chat-summary-api', ], config: { attachments: { diff --git a/src/components/ConversationSettings/LobbySettings.vue b/src/components/ConversationSettings/LobbySettings.vue index 4be75d97e50..9ca17fa695d 100644 --- a/src/components/ConversationSettings/LobbySettings.vue +++ b/src/components/ConversationSettings/LobbySettings.vue @@ -9,14 +9,12 @@ -
- - {{ t('spreed', 'Enable lobby, restricting the conversation to moderators') }} - -
+ + {{ t('spreed', 'Enable lobby, restricting the conversation to moderators') }} +
+
+

+ {{ t('spreed', 'Import email participants') }} +

+
+ {{ t('spreed', 'You can import a list of email participants from a CSV file.') }} +
+ + + {{ t('spreed', 'Import e-mail participants') }} + + + +
diff --git a/src/components/RightSidebar/LobbyStatus.vue b/src/components/RightSidebar/LobbyStatus.vue index a804bf0c333..0cd761467a0 100644 --- a/src/components/RightSidebar/LobbyStatus.vue +++ b/src/components/RightSidebar/LobbyStatus.vue @@ -3,59 +3,79 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> - + + + + + diff --git a/src/services/participantsService.js b/src/services/participantsService.js index cc44122cde3..dcd6ed153c0 100644 --- a/src/services/participantsService.js +++ b/src/services/participantsService.js @@ -165,6 +165,33 @@ const resendInvitations = async (token, attendeeId = null) => { attendeeId, }) } + +/** + * + * @param {string} token conversation token + * @param {File} file file to upload + * @param {boolean} testRun whether to perform a verification only + * @return {import('../types/index.ts').importEmailsResponse} + */ +const importEmails = async (token, file, testRun = false) => { + let data = { + file, + } + + if (testRun) { + data = { + file, + testRun, + } + } + + return axios.post(generateOcsUrl('apps/spreed/api/v4/room/{token}/import-emails', { token }), data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} + /** * Joins the current user to a conversation specified with * the token. @@ -265,6 +292,7 @@ export { demoteFromModerator, fetchParticipants, setGuestUserName, + importEmails, resendInvitations, sendCallNotification, grantAllPermissionsToParticipant, diff --git a/src/types/index.ts b/src/types/index.ts index aa9d3209d35..7e35b5f580a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -81,6 +81,9 @@ export type ParticipantSearchResult = { status: ParticipantStatus | '', } +export type importEmailsParams = Required['requestBody']['content']['application/json'] +export type importEmailsResponse = ApiResponse + // Chats export type Mention = RichObject<'server'|'call-type'|'icon-url'> export type File = RichObject<'size'|'path'|'link'|'mimetype'|'preview-available'> & { diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index a7474579726..b7f4b7b875b 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1317,7 +1317,7 @@ export type paths = { put?: never; /** * Import a list of email attendees - * @description Content format is comma separated values: - Header line is required and must match `"email","name"` or `"email"` - one entry per line + * @description Content format is comma separated values: - Header line is required and must match `"email","name"` or `"email"` - One entry per line (e.g. `"John Doe","john@example.tld"`) * Required capability: `email-csv-import` */ post: operations["room-import-emails-as-participants"]; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index de37a68ef97..0567e857d53 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1319,7 +1319,7 @@ export type paths = { put?: never; /** * Import a list of email attendees - * @description Content format is comma separated values: - Header line is required and must match `"email","name"` or `"email"` - one entry per line + * @description Content format is comma separated values: - Header line is required and must match `"email","name"` or `"email"` - One entry per line (e.g. `"John Doe","john@example.tld"`) * Required capability: `email-csv-import` */ post: operations["room-import-emails-as-participants"]; diff --git a/tests/php/GuestManagerTest.php b/tests/php/GuestManagerTest.php new file mode 100644 index 00000000000..17540aa6010 --- /dev/null +++ b/tests/php/GuestManagerTest.php @@ -0,0 +1,148 @@ +talkConfig = $this->createMock(Config::class); + $this->mailer = $this->createMock(IMailer::class); + $this->defaults = $this->createMock(Defaults::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->participantService = $this->createMock(ParticipantService::class); + $this->pollService = $this->createMock(PollService::class); + $this->roomService = $this->createMock(RoomService::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->l = $this->createMock(IL10N::class); + $this->dispatcher = $this->createMock(IEventDispatcher::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + public function getGuestManager(array $methods = []): GuestManager|MockObject { + if (!empty($methods)) { + return $this->getMockBuilder(GuestManager::class) + ->setConstructorArgs([ + $this->talkConfig, + $this->mailer, + $this->defaults, + $this->userSession, + $this->participantService, + $this->pollService, + $this->roomService, + $this->urlGenerator, + $this->l, + $this->dispatcher, + $this->logger, + ]) + ->onlyMethods($methods) + ->getMock(); + } + + $this->guestManager = new GuestManager( + $this->talkConfig, + $this->mailer, + $this->defaults, + $this->userSession, + $this->participantService, + $this->pollService, + $this->roomService, + $this->urlGenerator, + $this->l, + $this->dispatcher, + $this->logger, + ); + } + + public static function dataImportEmails(): array { + return [ + [ + 'import-valid-only-email.csv', + 1, + 0, + [['valid@example.tld', null]], + ], + [ + 'import-valid-email-and-name.csv', + 1, + 0, + [['valid@example.tld', 'Name']], + ], + [ + 'import-valid-filter-duplicates-by-email.csv', + 2, + 1, + [['valid-1@example.tld', 'Valid 1'], ['valid-2@example.tld',null]], + GuestImportException::REASON_ROWS, + [4], + ], + ]; + } + + /** + * @dataProvider dataImportEmails + */ + public function testImportEmails(string $fileName, int $invites, int $duplicates, array $invited, ?string $reason = null, array $invalidLines = []): void { + $this->mailer->method('validateMailAddress') + ->willReturnCallback(static fn (string $email): bool => str_starts_with($email, 'valid')); + + $actualInvites = []; + $this->participantService->method('inviteEmailAddress') + ->willReturnCallback(function ($room, $actorId, string $email, ?string $name) use (&$actualInvites): Participant { + $actualInvites[] = [$email, $name]; + return $this->createMock(Participant::class); + }); + + $room = $this->createMock(Room::class); + + try { + $guestManager = $this->getGuestManager(['sendEmailInvitation']); + $data = $guestManager->importEmails($room, __DIR__ . '/data/' . $fileName, false); + } catch (GuestImportException $e) { + if ($reason === null) { + throw $e; + } + + $data = $e->getData(); + $this->assertSame($invalidLines, $data['invalidLines']); + } + + $this->assertSame($invited, $actualInvites); + $this->assertSame($invites, $data['invites'], 'Invites count mismatch'); + $this->assertSame($duplicates, $data['duplicates'], 'Duplicates count mismatch'); + } +} diff --git a/tests/php/data/import-valid-email-and-name.csv b/tests/php/data/import-valid-email-and-name.csv new file mode 100644 index 00000000000..882ba909cda --- /dev/null +++ b/tests/php/data/import-valid-email-and-name.csv @@ -0,0 +1,2 @@ +"name","email" +"Name","valid@example.tld" diff --git a/tests/php/data/import-valid-email-and-name.csv.license b/tests/php/data/import-valid-email-and-name.csv.license new file mode 100644 index 00000000000..84f7e70446e --- /dev/null +++ b/tests/php/data/import-valid-email-and-name.csv.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/tests/php/data/import-valid-filter-duplicates-by-email.csv b/tests/php/data/import-valid-filter-duplicates-by-email.csv new file mode 100644 index 00000000000..2178633eac1 --- /dev/null +++ b/tests/php/data/import-valid-filter-duplicates-by-email.csv @@ -0,0 +1,5 @@ +"email","name" +"valid-1@example.tld","Valid 1" +"valid-2@example.tld","valid-2@example.tld" +"invalid","Valid 2" +"valid-1@example.tld","Valid 1 again" diff --git a/tests/php/data/import-valid-filter-duplicates-by-email.csv.license b/tests/php/data/import-valid-filter-duplicates-by-email.csv.license new file mode 100644 index 00000000000..84f7e70446e --- /dev/null +++ b/tests/php/data/import-valid-filter-duplicates-by-email.csv.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/tests/php/data/import-valid-only-email.csv b/tests/php/data/import-valid-only-email.csv new file mode 100644 index 00000000000..339e4f8c07a --- /dev/null +++ b/tests/php/data/import-valid-only-email.csv @@ -0,0 +1,2 @@ +"email" +"valid@example.tld" diff --git a/tests/php/data/import-valid-only-email.csv.license b/tests/php/data/import-valid-only-email.csv.license new file mode 100644 index 00000000000..84f7e70446e --- /dev/null +++ b/tests/php/data/import-valid-only-email.csv.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later