diff --git a/appinfo/info.xml b/appinfo/info.xml index 249c4c20b3..cb6c9d3adc 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -49,8 +49,13 @@ OCA\Contacts\Cron\SocialUpdateRegistration + OCA\Contacts\Cron\UpdateOcmProviders + + OCA\Contacts\Command\OcmInvitesConfig + + OCA\Contacts\Settings\AdminSettings diff --git a/jest.config.cjs b/jest.config.cjs index 168bf17c54..86088350fc 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -10,7 +10,7 @@ module.exports = { preset: 'ts-jest', moduleFileExtensions: ['js', 'vue', 'ts'], collectCoverageFrom: [ - 'src/**/*.{js,vue}', + 'src/**/*.{js,ts,vue}', '!**/node_modules/**', ], setupFilesAfterEnv: [ diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index ac43828a3c..6a951b2785 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -7,10 +7,13 @@ namespace OCA\Contacts\AppInfo; use OCA\Contacts\Capabilities; +use OCA\Contacts\ConfigLexicon; use OCA\Contacts\Dav\PatchPlugin; use OCA\Contacts\Event\LoadContactsOcaApiEvent; +use OCA\Contacts\Listener\FederatedInviteAcceptedListener; use OCA\Contacts\Listener\LoadContactsFilesActions; use OCA\Contacts\Listener\LoadContactsOcaApi; +use OCA\Contacts\Listener\OcmDiscoveryListener; use OCA\DAV\Events\SabrePluginAddEvent; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCP\AppFramework\App; @@ -18,6 +21,9 @@ use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\EventDispatcher\IEventDispatcher; +use OCP\OCM\Events\LocalOCMDiscoveryEvent; +use OCP\OCM\Events\OCMEndpointRequestEvent; +use OCP\OCM\Events\ResourceTypeRegisterEvent; class Application extends App implements IBootstrap { public const APP_ID = 'contacts'; @@ -33,8 +39,15 @@ public function __construct() { #[\Override] public function register(IRegistrationContext $context): void { $context->registerCapability(Capabilities::class); + $context->registerConfigLexicon(ConfigLexicon::class); + $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadContactsFilesActions::class); $context->registerEventListener(LoadContactsOcaApiEvent::class, LoadContactsOcaApi::class); + $context->registerEventListener(OCMEndpointRequestEvent::class, FederatedInviteAcceptedListener::class); + $ocmDiscoveryEvent = class_exists(LocalOCMDiscoveryEvent::class) + ? LocalOCMDiscoveryEvent::class + : ResourceTypeRegisterEvent::class; + $context->registerEventListener($ocmDiscoveryEvent, OcmDiscoveryListener::class); } #[\Override] diff --git a/lib/Command/OcmInvitesConfig.php b/lib/Command/OcmInvitesConfig.php new file mode 100644 index 0000000000..dbb17d3b27 --- /dev/null +++ b/lib/Command/OcmInvitesConfig.php @@ -0,0 +1,189 @@ + + * occ contacts:ocm-invites-config + * + * Boolean values accept on/off, true/false, 1/0, and yes/no. + */ +class OcmInvitesConfig extends Command { + public function __construct( + private IAppConfig $appConfig, + ) { + parent::__construct(); + } + + #[\Override] + protected function configure(): void { + $supportedOptions = implode(', ', $this->getSupportedOptions()); + $help = <<%command.name% command reads and writes Contacts OCM invite settings. + +Supported keys: + {$supportedOptions} + +Key info: + ocm_invites_enabled - If set to true then the capability to send and accept invitations to exchange contact info is enabled in the app - bool, default false + ocm_invites_optional_mail - If set to true then sending an invitation by email is optional - bool, default false + ocm_invites_cc_sender - If set to true then the option to send a copy of the invitation to the sender is displayed - bool, default true + ocm_invites_encoded_copy_button - If set to true then the button to copy the encoded invitation is displayed - bool, default false + ocm_invites_disable_ssrf_guard - If set to true SSRF guard will be turned off. Warning: This is for development/testing purposes only! + In production environments the value of this key should always be false - bool, default false + mesh_providers_service - The url that returns the list of mesh providers that will be displayed on the WAYF page - string, default empty + +Boolean values accept: on/off, true/false, 1/0, yes/no. + +Examples: + occ %command.name% + occ %command.name% ocm_invites_enabled on + occ %command.name% mesh_providers_service "https://mesh.example" + +HELP; + + $this + ->setName('contacts:ocm-invites-config') + ->setDescription('Manage OCM invite configuration.') + ->addArgument( + 'option', + InputArgument::OPTIONAL, + 'Config key to read or write. Omit to list supported keys.', + ) + ->addArgument( + 'value', + InputArgument::OPTIONAL, + 'Value to write. Omit to read the current value.', + ) + ->setHelp($help); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + $option = $input->getArgument('option'); + $value = $input->getArgument('value'); + + if ($option === null) { + return $this->listAll($output); + } + + if (!in_array($option, $this->getSupportedOptions(), true)) { + $output->writeln(sprintf( + 'Unknown OCM invite config key "%s". Allowed: %s.', + $option, + implode(', ', $this->getSupportedOptions()), + )); + return self::FAILURE; + } + + if ($value === null) { + $output->writeln($this->getCurrentValue($option)); + return self::SUCCESS; + } + + if ($option === ConfigLexicon::MESH_PROVIDERS_SERVICE) { + $normalised = trim($value); + $current = $this->appConfig->getValueString(Application::APP_ID, $option); + if ($current === $normalised) { + $output->writeln(sprintf('%s is already "%s".', $option, $normalised)); + return self::SUCCESS; + } + + $this->appConfig->setValueString(Application::APP_ID, $option, $normalised); + $output->writeln(sprintf('%s: "%s"', $option, $normalised)); + return self::SUCCESS; + } + + $parsed = $this->parseBool($value); + if ($parsed === null) { + $output->writeln(sprintf( + 'Cannot parse "%s" as boolean. Use on/off, true/false, 1/0, or yes/no.', + $value, + )); + return self::INVALID; + } + + $current = $this->appConfig->getValueBool(Application::APP_ID, $option); + if ($current === $parsed) { + $output->writeln(sprintf('%s is already %s.', $option, $this->formatBool($parsed))); + return self::SUCCESS; + } + + $this->appConfig->setValueBool(Application::APP_ID, $option, $parsed); + $output->writeln(sprintf('%s: %s', $option, $this->formatBool($parsed))); + return self::SUCCESS; + } + + private function listAll(OutputInterface $output): int { + $table = new Table($output); + $table->setHeaders(['option', 'type', 'value']); + foreach ($this->getSupportedOptions() as $key) { + $table->addRow([ + $key, + $this->isBooleanOption($key) ? 'bool' : 'string', + $this->getCurrentValue($key), + ]); + } + $table->render(); + return self::SUCCESS; + } + + private function getSupportedOptions(): array { + return [ + ConfigLexicon::OCM_INVITES_ENABLED, + ...FederatedInvitesService::OCM_INVITES_BOOL_KEYS, + ConfigLexicon::MESH_PROVIDERS_SERVICE, + ]; + } + + private function isBooleanOption(string $option): bool { + return in_array($option, [ + ConfigLexicon::OCM_INVITES_ENABLED, + ...FederatedInvitesService::OCM_INVITES_BOOL_KEYS, + ], true); + } + + private function getCurrentValue(string $option): string { + if ($this->isBooleanOption($option)) { + return $this->formatBool($this->appConfig->getValueBool(Application::APP_ID, $option)); + } + + return $this->appConfig->getValueString(Application::APP_ID, $option); + } + + private function formatBool(bool $value): string { + return $value ? 'on' : 'off'; + } + + private function parseBool(string $raw): ?bool { + $normalised = strtolower(trim($raw)); + if (in_array($normalised, ['true', '1', 'on', 'yes'], true)) { + return true; + } + if (in_array($normalised, ['false', '0', 'off', 'no'], true)) { + return false; + } + return null; + } +} diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php new file mode 100644 index 0000000000..e0f6186aeb --- /dev/null +++ b/lib/ConfigLexicon.php @@ -0,0 +1,109 @@ +requireOcmInvitesEnabled()) !== null) { + return $disabled; + } + + $uid = $this->userSession->getUser()->getUID(); + try { + $_invites = $this->federatedInviteMapper->findOpenInvitesByUid($uid); + $invites = []; + foreach ($_invites as $invite) { + if ($invite instanceof FederatedInvite) { + array_push( + $invites, + $invite->jsonSerialize() + ); + } + } + return new JSONResponse($invites, Http::STATUS_OK); + } catch (Exception $e) { + $this->logger->error("An unexpected error occurred loading invites for user with uid=$uid. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]); + return new JSONResponse([ + 'code' => 'ocm_invites_fetch_failed', + 'message' => 'Could not load invites.', + ], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Deletes the invite with the specified token. + * + * @param string $token the token of the invite to delete + * @return JSONResponse with data signature ['token' | 'message'] - the token of the deleted invitation or an error message in case of error + */ + #[NoAdminRequired] + #[FrontpageRoute(verb: 'DELETE', url: '/ocm/invitations/{token}')] + public function deleteInvite(string $token): JSONResponse { + if (($disabled = $this->requireOcmInvitesEnabled()) !== null) { + return $disabled; + } + + try { + $uid = $this->userSession->getUser()->getUID(); + $invite = $this->federatedInviteMapper->findInviteByTokenAndUid($token, $uid); + $this->federatedInviteMapper->delete($invite); + return new JSONResponse(['token' => $token], Http::STATUS_OK); + } catch (DoesNotExistException $e) { + $this->logger->warning("Could not find invite with token=$token for user with uid=$uid", ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'Invite not found'], Http::STATUS_NOT_FOUND); + } catch (Exception $e) { + $this->logger->error("An unexpected error occurred deleting invite with token=$token. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'An unexpected error occurred trying to delete the invite'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Results in displaying the invite accept dialog upon following the invite link. + * + * @param string $token + * @param string $providerDomain + * @return TemplateResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE)] + public function inviteAcceptDialog(string $token = '', string $providerDomain = ''): TemplateResponse { + $this->initialState->provideInitialState('inviteToken', $token); + $this->initialState->provideInitialState('inviteProvider', $providerDomain); + $this->initialState->provideInitialState('acceptInviteDialogUrl', FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE); + + return $this->index(); + } + + /** + * Creates an invitation to exchange contact info with the remote user. + * + * @param string $email the recipient email address to send the invitation to (optional) + * @param string $message the optional message to send with the invitation + * @param string $note optional note/label for identifying the invite + * @param bool $ccSender whether to send a copy of the invite to the sender + * @return JSONResponse with data signature ['invite' | 'message'] - the invite url or an error message in case of error. + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 60, period: 3600)] + #[BruteForceProtection(action: 'ocmInviteCreate')] + #[FrontpageRoute(verb: 'POST', url: '/ocm/invitations')] + public function createInvite(string $email = '', string $message = '', string $note = '', bool $ccSender = false): JSONResponse { + if (($disabled = $this->requireOcmInvitesEnabled()) !== null) { + return $disabled; + } + + // Enforce email required when optional mail is disabled + if (empty($email) && !$this->federatedInvitesService->isOptionalMailEnabled()) { + return new JSONResponse(['message' => $this->il10->t('Email address is required.')], Http::STATUS_BAD_REQUEST); + } + + $uid = $this->userSession->getUser()->getUID(); + if (!empty($email)) { + $validationError = $this->validateEmail($email); + if ($validationError !== null) { + return $validationError; + } + + $this->cleanupSupersededInvitesForRecipient($uid, $email); + + // check for existing open invite for the specified email, only if email provided + $existingInvites = $this->federatedInviteMapper->findOpenInvitesByRecipientEmail( + $uid, + $email, + ); + if (count($existingInvites) > 0) { + $this->logger->error("An open invite already exists for user with uid $uid and for recipient email $email", ['app' => Application::APP_ID]); + return new JSONResponse(['message' => $this->il10->t('An open invite already exists.')], Http::STATUS_CONFLICT); + } + } + + $invite = new FederatedInvite(); + $invite->setUserId($uid); + $token = UUIDUtil::getUUID(); + $invite->setToken($token); + // created-/expiredAt in seconds + $invite->setCreatedAt($this->timeFactory->now()->getTimestamp()); + $invite->setExpiredAt($this->federatedInvitesService->getInviteExpirationDate($invite->getCreatedAt())); + if (!empty($email)) { + $invite->setRecipientEmail($email); + } + // Store note in recipientName field (used as label until invite is accepted) + if (!empty($note)) { + $invite->setRecipientName($note); + } + $invite->setAccepted(false); + $inserted = false; + try { + $this->federatedInviteMapper->insert($invite); + $inserted = true; + } catch (Throwable $e) { + if ($this->isDuplicateConstraintException($e)) { + if (!empty($email) && $this->cleanupSupersededInvitesForRecipient($uid, $email) > 0) { + try { + $this->federatedInviteMapper->insert($invite); + $inserted = true; + } catch (Throwable $retry) { + if (!$this->isDuplicateConstraintException($retry)) { + $this->logger->error('An unexpected error occurred saving a new invite after stale cleanup. Stacktrace: ' . $retry->getTraceAsString(), ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'An unexpected error occurred creating the invite.'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + } + if (!$inserted) { + return new JSONResponse(['message' => $this->il10->t('An open invite already exists.')], Http::STATUS_CONFLICT); + } + } else { + $this->logger->error('An unexpected error occurred saving a new invite. Stacktrace: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'An unexpected error occurred creating the invite.'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + $senderProvider = $this->federatedInvitesService->getProviderFQDN(); + + // Only send email if email address provided + if (!empty($email)) { + /** @var JSONResponse */ + $response = $this->sendEmail($token, $senderProvider, $email, $message); + if ($response->getStatus() !== Http::STATUS_OK) { + // delete invite in case sending the email has failed + try { + $this->federatedInviteMapper->delete($invite); + } catch (Exception $e) { + $this->logger->error("An unexpected error occurred deleting invite with token $token. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'An unexpected error occurred creating the invite.'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + return $response; + } + + // Send CC to sender if requested and enabled + if ($ccSender && $this->federatedInvitesService->isCcSenderEnabled()) { + $senderEmail = $this->userSession->getUser()->getEMailAddress(); + if (!empty($senderEmail)) { + $this->sendCcEmail($token, $senderProvider, $senderEmail, $email, $message); + } + } + } + + // invite url, use token instead of email for routing + $inviteUrl = $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkToRoute('contacts.page.index') . 'ocm-invites/' . $token + ); + return new JSONResponse(['invite' => $inviteUrl], Http::STATUS_OK); + } + + /** + * Accepts the invite and creates a new contact from the inviter. + * On success the user is redirected to the new contact url. + * + * @param string $token the token of the invite + * @param string $provider the provider of the sender of the invite + * @return JSONResponse with data signature ['contact' | 'message'] - the new contact url or an error message in case of error + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 60, period: 3600)] + #[BruteForceProtection(action: 'ocmInviteAccept')] + #[FrontpageRoute(verb: 'PATCH', url: '/ocm/invitations/{token}/accept')] + public function acceptInvite(string $token = '', string $provider = ''): JSONResponse { + if (($disabled = $this->requireOcmInvitesEnabled()) !== null) { + return $disabled; + } + + if ($token === '' || $provider === '') { + $this->logger->error("Both token and provider must be specified. Received: token=$token, provider=$provider", ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'Both invite code and provider must be specified.'], Http::STATUS_BAD_REQUEST); + } + $localUser = $this->userSession->getUser(); + if ($localUser === null) { + return new JSONResponse(['message' => $this->il10->t('Could not accept invite because no authenticated user was found.')], Http::STATUS_UNAUTHORIZED); + } + $provider = $this->normalizeProviderBase($provider); + if ($provider === null) { + return new JSONResponse(['message' => $this->il10->t('The invite provider is invalid or not allowed.')], Http::STATUS_BAD_REQUEST); + } + $recipientProvider = $this->federatedInvitesService->getProviderFQDN(); + $userId = $localUser->getUID(); + $email = $localUser->getEMailAddress(); + $name = $localUser->getDisplayName(); + if ($recipientProvider === '' || $userId === '' || $email === '' || $name === '') { + $this->logger->error("All of these must be set: recipientProvider: $recipientProvider, email: $email, userId: $userId, name: $name", ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'Could not accept invite, user data is incomplete.'], Http::STATUS_UNPROCESSABLE_ENTITY); + } + $cloudId = ''; + try { + // accept the invite by calling provider OCM /invite-accepted + // this returns a response with the following data signature: ['userID', 'email', 'name'] + // @link https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post + $client = $this->httpClient->newClient(); + /** + * @var \OCP\OCM\ICapabilityAwareOCMProvider $discovered + * + */ + $discovered = $this->discovery->discover($provider); + $capabilities = $discovered->getCapabilities(); + // Accept both the canonical advertised capability and older aliases. + if ( + in_array('invites', $capabilities, true) + || in_array('invite-accepted', $capabilities, true) + || in_array('/invite-accepted', $capabilities, true) + ) { + + $response = $this->discovery->requestRemoteOcmEndpoint( + null, + $provider, + '/invite-accepted', + [ + 'recipientProvider' => $recipientProvider, + 'token' => $token, + 'userID' => $userId, + 'email' => $email, + 'name' => $name + ], + 'POST', + $client + ); + $responseData = $response->getBody(); + $data = json_decode($responseData, true); + if ( + !is_array($data) + || !isset($data['userID'], $data['email'], $data['name']) + || !is_string($data['userID']) + || !is_string($data['email']) + || !is_string($data['name']) + || trim($data['userID']) === '' + || trim($data['email']) === '' + || trim($data['name']) === '' + ) { + $this->logger->warning('Invalid /invite-accepted payload from provider', [ + 'app' => Application::APP_ID, + 'provider' => $provider, + 'payload' => $responseData, + ]); + return new JSONResponse(['message' => $this->il10->t('Could not accept invite because the remote provider returned an invalid response.')], Http::STATUS_BAD_GATEWAY); + } + + $cloudId = $data['userID'] . '@' . $this->addressHandler->removeProtocolFromUrl($provider); + + $contactRef = $this->federatedInvitesService->createNewContact( + $cloudId, + $data['email'], + $data['name'], + null + ); + if (!isset($contactRef)) { + $this->logger->error('Remote invite acceptance succeeded but local contact creation failed', [ + 'app' => Application::APP_ID, + 'token' => $token, + 'provider' => $provider, + 'cloudId' => $cloudId, + 'userId' => $userId, + ]); + return new JSONResponse([ + 'code' => 'ocm_invite_local_contact_create_failed', + 'message' => $this->il10->t('The remote provider accepted the invite, but this server could not create the local contact.'), + ], Http::STATUS_BAD_GATEWAY); + } + $key = base64_encode($contactRef); + $contactUrl = $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkToRoute('contacts.page.index') . $this->il10->t('All contacts') . '/' . $key + ); + return new JSONResponse(['contact' => $contactUrl], Http::STATUS_OK); + } else { + $this->logger->error('Provider: ' . $provider . ' does not support invites.', ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'Provider: ' . $provider . ' does not support invites.'], Http::STATUS_BAD_REQUEST); + } + } catch (ContactExistsException $e) { + return new JSONResponse(['message' => 'Contact with cloudID ' . $cloudId . ' already exists.'], Http::STATUS_CONFLICT); + } catch (RequestException $e) { // this should catch OCM API request exceptions + $this->logger->error('/invite-accepted returned an error: ' . $e->getMessage(), ['app' => Application::APP_ID]); + /** + * 400: Invalid or non existing token + * 409: Invite already accepted + */ + $statusCode = $e->getResponse() !== null + ? $e->getResponse()->getStatusCode() + : null; + switch ($statusCode) { + case Http::STATUS_BAD_REQUEST: + return new JSONResponse(['message' => 'Invalid, non-existing, or expired invite code.'], Http::STATUS_BAD_REQUEST); + case Http::STATUS_CONFLICT: + return new JSONResponse(['message' => 'Invite already accepted'], Http::STATUS_CONFLICT); + } + $this->logger->error("An unexpected error occurred accepting invite with token=$token and provider=$provider. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'An unexpected error occurred trying to accept invite.'], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (OCMProviderException|OCMRequestException|Exception $e) { + $this->logger->error("An unexpected error occurred accepting invite with token=$token and provider=$provider. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'An unexpected error occurred trying to accept invite'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Re-sends an existing invite email while preserving invite lifetime metadata. + * + * + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 30, period: 3600)] + #[BruteForceProtection(action: 'ocmInviteResend')] + #[FrontpageRoute(verb: 'PATCH', url: '/ocm/invitations/{token}/resend')] + public function resendInvite(string $token): JSONResponse { + if (($disabled = $this->requireOcmInvitesEnabled()) !== null) { + return $disabled; + } + + $uid = $this->userSession->getUser()->getUID(); + try { + $invite = $this->federatedInviteMapper->findInviteByTokenAndUid($token, $uid); + } catch (DoesNotExistException $e) { + $this->logger->warning("Could not find invite with token=$token for user with uid=$uid", ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'Invite not found'], Http::STATUS_NOT_FOUND); + } catch (Exception $e) { + $this->logger->error("An unexpected error occurred loading invite with token=$token. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'An unexpected error occurred trying to resend the invite'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + // Cannot resend if no email address + if (empty($invite->getRecipientEmail())) { + return new JSONResponse(['message' => $this->il10->t('Cannot resend: no email address')], Http::STATUS_UNPROCESSABLE_ENTITY); + } + if ($this->isInviteAccepted($invite)) { + return new JSONResponse([ + 'code' => 'ocm_invite_already_accepted', + 'message' => $this->il10->t('Invite has already been accepted.'), + ], Http::STATUS_CONFLICT); + } + if ($this->isInviteExpired($invite)) { + return new JSONResponse([ + 'code' => 'ocm_invite_expired', + 'message' => $this->il10->t('Invite has expired. Please create a new one.'), + ], Http::STATUS_GONE); + } + + $sendDate = date('Y-m-d', $invite->getCreatedAt()); + $initiatorDisplayName = $this->userSession->getUser()->getDisplayName(); + // a resend notification that refers to the previously sent invite + $message = $this->il10->t( + 'This is a copy of an invite sent to you previously by %1$s on %2$s', + [ + $initiatorDisplayName, + $sendDate + ] + ); + $senderProvider = $this->federatedInvitesService->getProviderFQDN(); + /** @var JSONResponse */ + $response = $this->sendEmail($token, $senderProvider, $invite->getRecipientEmail(), $message); + if ($response->getStatus() !== Http::STATUS_OK) { + $this->logger->error("An unexpected error occurred resending the invite with token $token. HTTP response status: " . $response->getStatus(), ['app' => Application::APP_ID]); + return $response; + } + + // the invite url, use token instead of email for routing + $inviteUrl = $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkToRoute('contacts.page.index') . 'ocm-invites/' . $invite->getToken() + ); + return new JSONResponse(['invite' => $inviteUrl], Http::STATUS_OK); + } + + /** + * Attaches a recipient email to an existing link-only invite and sends the + * invitation email. Refreshes the creation and expiration timestamps so the + * recipient receives a fresh expiry window. Reverts both the email and the + * timestamps if the mailer fails, so a failed call leaves the invite as it + * was before. + * + * @param string $token the invite token + * @param string $email the recipient email address + * @param string $message the optional message to include in the email + * @return JSONResponse the serialized invite on success or an error message + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 30, period: 3600)] + #[BruteForceProtection(action: 'ocmInviteAttachEmail')] + #[FrontpageRoute(verb: 'PATCH', url: '/ocm/invitations/{token}/email')] + public function attachEmailAndSend(string $token, string $email = '', string $message = ''): JSONResponse { + if (($disabled = $this->requireOcmInvitesEnabled()) !== null) { + return $disabled; + } + + $uid = $this->userSession->getUser()->getUID(); + try { + $invite = $this->federatedInviteMapper->findInviteByTokenAndUid($token, $uid); + } catch (DoesNotExistException $e) { + $this->logger->warning("Could not find invite with token=$token for user with uid=$uid", ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'Invite not found'], Http::STATUS_NOT_FOUND); + } catch (Exception $e) { + $this->logger->error("An unexpected error occurred loading invite with token=$token. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'An unexpected error occurred attaching the email.'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($this->isInviteAccepted($invite)) { + return new JSONResponse([ + 'code' => 'ocm_invite_already_accepted', + 'message' => $this->il10->t('Invite has already been accepted.'), + ], Http::STATUS_CONFLICT); + } + if (!empty($invite->getRecipientEmail())) { + return new JSONResponse([ + 'code' => 'ocm_invite_already_has_email', + 'message' => $this->il10->t('Invite already has an email address.'), + ], Http::STATUS_CONFLICT); + } + + if (empty($email)) { + return new JSONResponse([ + 'code' => 'ocm_invite_email_required', + 'message' => $this->il10->t('Email address is required.'), + ], Http::STATUS_BAD_REQUEST); + } + $validationError = $this->validateEmail($email); + if ($validationError !== null) { + return $validationError; + } + + $this->cleanupSupersededInvitesForRecipient($uid, $email); + + // Reject when another open invite from this user already targets the same email. + // The current invite is excluded by construction: it has no recipient_email yet + // and findOpenInvitesByRecipientEmail() filters by recipient_email. + $existingInvites = $this->federatedInviteMapper->findOpenInvitesByRecipientEmail($uid, $email); + if (count($existingInvites) > 0) { + $this->logger->error("An open invite already exists for user with uid $uid and for recipient email $email", ['app' => Application::APP_ID]); + return new JSONResponse([ + 'code' => 'ocm_invite_duplicate_recipient_email', + 'message' => $this->il10->t('An open invite for this email already exists.'), + ], Http::STATUS_CONFLICT); + } + + $previousCreatedAt = $invite->getCreatedAt(); + $previousExpiredAt = $invite->getExpiredAt(); + $newCreatedAt = $this->timeFactory->now()->getTimestamp(); + $newExpiredAt = $this->federatedInvitesService->getInviteExpirationDate($newCreatedAt); + + try { + $claimed = $this->federatedInviteMapper->claimInviteForEmail( + $token, + $uid, + $email, + $newCreatedAt, + $newExpiredAt, + ); + } catch (Exception $e) { + $this->logger->error("An unexpected error occurred claiming invite with token=$token. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]); + return new JSONResponse([ + 'code' => 'ocm_invite_claim_exception', + 'message' => 'An unexpected error occurred attaching the email.', + ], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($claimed === false) { + // A concurrent attach won the race or the invite was accepted between + // the read and the conditional update. Treat as a 409 collision so the + // client can refresh and decide what to do next. + return new JSONResponse([ + 'code' => 'ocm_invite_claim_failed', + 'message' => $this->il10->t('Could not claim invite for this email; please refresh and try again.'), + ], Http::STATUS_CONFLICT); + } + + $invite->setRecipientEmail($email); + $invite->setCreatedAt($newCreatedAt); + $invite->setExpiredAt($newExpiredAt); + + $senderProvider = $this->federatedInvitesService->getProviderFQDN(); + /** @var JSONResponse */ + $response = $this->sendEmail($token, $senderProvider, $email, $message); + if ($response->getStatus() !== Http::STATUS_OK) { + $this->logger->error("An unexpected error occurred sending the invite with token $token. HTTP response status: " . $response->getStatus(), ['app' => Application::APP_ID]); + $reverted = false; + try { + $reverted = $this->federatedInviteMapper->revertInviteEmail( + $token, + $uid, + $email, + $previousCreatedAt, + $previousExpiredAt, + ); + } catch (Exception $e) { + $this->logger->error("Could not revert invite with token=$token after mailer failure. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]); + } + if ($reverted !== true) { + $mailFailure = $response->getData(); + $mailMessage = is_array($mailFailure) && isset($mailFailure['message']) && is_string($mailFailure['message']) + ? $mailFailure['message'] + : null; + return new JSONResponse([ + 'code' => 'ocm_invite_revert_failed', + 'message' => $this->il10->t('Could not revert invite after delivery failure. Please refresh and try again.'), + 'mailError' => $mailMessage, + ], $response->getStatus()); + } + return $response; + } + + return new JSONResponse($invite->jsonSerialize(), Http::STATUS_OK); + } + + /** + * Do OCM discovery on behalf of VUE frontend to avoid CSRF issues + * @param string $base base url to discover + * @return DataResponse + */ + #[PublicPage] + #[AnonRateLimit(limit: 120, period: 3600)] + #[UserRateLimit(limit: 120, period: 3600)] + #[BruteForceProtection(action: 'ocmInviteDiscover')] + #[FrontpageRoute(verb: 'GET', url: '/discover')] + public function discover(string $base): DataResponse { + if (!$this->federatedInvitesService->isOcmInvitesEnabled()) { + return new DataResponse([ + 'code' => 'ocm_invites_disabled', + 'error' => $this->il10->t('OCM invites are disabled.'), + ], Http::STATUS_FORBIDDEN); + } + + $base = $this->normalizeProviderBase($base); + if ($base === null) { + return new DataResponse(['error' => 'invalid base'], Http::STATUS_BAD_REQUEST); + } + + try { + /** + * @var \OCP\OCM\ICapabilityAwareOCMProvider $provider + * + */ + $provider = $this->discovery->discover($base); + $dialog = trim((string)$provider->getInviteAcceptDialog()); + $absolute = $dialog === '' ? null : $this->buildInviteAcceptDialogAbsolute($base, $dialog); + if ($absolute === null) { + $dialog = $this->wayfProvider->getInviteAcceptDialogPath(); + $absolute = $this->buildInviteAcceptDialogAbsolute($base, $dialog); + } + if ($absolute === null) { + return new DataResponse(['error' => 'OCM discovery failed', 'base' => $base], Http::STATUS_NOT_FOUND); + } + + $baseHost = parse_url($base, PHP_URL_HOST); + return new DataResponse([ + 'base' => $base, + 'inviteAcceptDialog' => $dialog, + 'inviteAcceptDialogAbsolute' => $absolute, + 'providerDomain' => is_string($baseHost) ? $baseHost : '', + ], Http::STATUS_OK); + } catch (Throwable $e) { + $this->logger->warning('OCM discovery failed', [ + 'app' => Application::APP_ID, + 'base' => $base, + 'exception' => $e, + ]); + return new DataResponse(['error' => 'OCM discovery failed', 'base' => $base], Http::STATUS_NOT_FOUND); + } + } + + /** + * Accepts the invite and creates a new contact from the inviter. + * On success the user is redirected to the new contact url. + * + * @param string $token the token of the invite + * @param string $provider the provider of the sender of the invite + * @return TemplateResponse the WAYF page + */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/wayf')] + public function wayf(string $token = ''): TemplateResponse { + Util::addScript(Application::APP_ID, 'contacts-wayf'); + Util::addStyle(Application::APP_ID, 'contacts-wayf'); + try { + $federations = $this->wayfProvider->getMeshProvidersFromCache(); + $providerDomain = trim((string)$this->request->getParam('providerDomain', '')); + if ($providerDomain === '') { + $baseHost = parse_url($this->urlGenerator->getBaseUrl(), PHP_URL_HOST); + $providerDomain = is_string($baseHost) ? $baseHost : ''; + } + $this->initialState->provideInitialState('wayf', [ + 'federations' => $federations, + 'providerDomain' => $providerDomain, + 'token' => $token, + ]); + } catch (Exception $e) { + $this->logger->error($e->getMessage() . ' Trace: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]); + } + $template = new TemplateResponse('contacts', 'wayf', [], TemplateResponse::RENDER_AS_GUEST); + return $template; + } + + /** + * Sends a copy of the invite email to the sender. + * + * @param string $token the invite token + * @param string $senderProvider this provider + * @param string $senderAddress the sender's email address + * @param string $recipientAddress the original recipient's email address + * @param string $message the optional message included in the invite + */ + private function sendCcEmail(string $token, string $senderProvider, string $senderAddress, string $recipientAddress, string $message): void { + try { + $email = $this->mailer->createMessage(); + $email->setTo([$senderAddress]); + + $instanceName = $this->defaults->getName(); + $initiatorDisplayName = $this->userSession->getUser()->getDisplayName(); + $senderName = $this->il10->t( + '%1$s via %2$s', + [$initiatorDisplayName, $instanceName] + ); + $email->setFrom([Util::getDefaultEmailAddress($instanceName) => $senderName]); + $subject = $this->il10->t('[Copy] Invite sent to %1$s', [$recipientAddress]); + $email->setSubject($subject); + + $wayfEndpoint = $this->wayfProvider->getWayfEndpoint(); + if ($wayfEndpoint === null || trim($wayfEndpoint) === '') { + return; + } + $inviteLink = $this->buildWayfInviteLink($wayfEndpoint, $token, $senderProvider); + $encoded = base64_encode("$token@$senderProvider"); + + $recipientH = htmlspecialchars($recipientAddress, ENT_QUOTES, 'UTF-8'); + $inviteLinkH = htmlspecialchars($inviteLink, ENT_QUOTES, 'UTF-8'); + $tokenSenderH = htmlspecialchars("$token@$senderProvider", ENT_QUOTES, 'UTF-8'); + $encodedH = htmlspecialchars($encoded, ENT_QUOTES, 'UTF-8'); + $messageH = nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), false); + + $header = $this->il10->t('This is a copy of the invite you sent to %1$s.

', [$recipientH]); + $inviteLinkNote = $this->il10->t('Invite link: %1$s
', [$inviteLinkH]); + $inviteDetails = $this->il10->t('Invite code: %1$s
Encoded invite: %2$s
', [$tokenSenderH, $encodedH]); + $messageHeading = $this->il10->t('Your message:'); + $messageSection = trim($message) === '' ? '' : "
$messageHeading
$messageH
"; + + $htmlBody = "$header$messageSection$inviteLinkNote$inviteDetails"; + $email->setHtmlBody($htmlBody); + + $plainHeader = $this->il10->t('This is a copy of the invite you sent to %1$s.', [$recipientAddress]); + $plainInviteLine = $this->il10->t('Invite link: %1$s', [$inviteLink]); + $plainInviteCode = $this->il10->t('Invite code: %1$s', ["$token@$senderProvider"]); + $plainEncoded = $this->il10->t('Encoded invite: %1$s', [$encoded]); + $plainMessageSection = trim($message) === '' ? '' : "\n$messageHeading\n$message\n"; + $plainBody = "$plainHeader\n$plainMessageSection\n$plainInviteLine\n$plainInviteCode\n$plainEncoded\n"; + $email->setPlainBody($plainBody); + + $this->mailer->send($email); + } catch (Exception $e) { + $this->logger->warning('Could not send CC email to sender: ' . $e->getMessage(), ['app' => Application::APP_ID]); + } + } + + /** + * Persist an OCM invite bool admin setting. Admin-only by default since the + * method is not marked with NoAdminRequired. + * + * @param string $key one of FederatedInvitesService::OCM_INVITES_BOOL_KEYS + * @param bool $value the new value + * @return JSONResponse empty body with the appropriate HTTP status + */ + #[FrontpageRoute(verb: 'PUT', url: '/ocm/admin/settings/{key}')] + public function setOcmInviteBoolSetting(string $key, bool $value): JSONResponse { + if (!$this->federatedInvitesService->setOcmInviteBoolSetting($key, $value)) { + return new JSONResponse(['message' => 'Unknown setting key'], Http::STATUS_FORBIDDEN); + } + return new JSONResponse([], Http::STATUS_OK); + } + + /** + * Validate a recipient email address against the configured mailer. + * + * @return JSONResponse|null Error response on invalid input, null when valid. + */ + private function validateEmail(string $address): ?JSONResponse { + if (!$this->mailer->validateMailAddress($address)) { + $this->logger->debug("Invalid recipient email address '$address'", ['app' => Application::APP_ID]); + return new JSONResponse(['message' => $this->il10->t('Recipient email address is invalid.')], Http::STATUS_UNPROCESSABLE_ENTITY); + } + return null; + } + + /** + * @param string $token the invite token + * @param string $senderProvider this provider + * @param string $address the recipient email address to send the invitation to + * @param string $message the optional message to send with the invitation + * @return JSONResponse + */ + private function sendEmail(string $token, string $senderProvider, string $address, string $message): JSONResponse { + $validationError = $this->validateEmail($address); + if ($validationError !== null) { + return $validationError; + } + /** @var IMessage */ + $email = $this->mailer->createMessage(); + $email->setTo([$address]); + + $instanceName = $this->defaults->getName(); + $initiatorDisplayName = $this->userSession->getUser()->getDisplayName(); + $senderName = $this->il10->t( + '%1$s via %2$s', + [ + $initiatorDisplayName, + $instanceName + ] + ); + $email->setFrom([Util::getDefaultEmailAddress($instanceName) => $senderName]); + $subject = $this->il10->t('%1$s invites you to share contacts using your cloud account', [$initiatorDisplayName]); + $email->setSubject($subject); + + $wayfEndpoint = $this->wayfProvider->getWayfEndpoint(); + if (empty($wayfEndpoint)) { + $this->logger->error('Invalid WAYF endpoint (null).', ['app' => Application::APP_ID]); + return new JSONResponse(['message' => 'Could not send invite.'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $inviteLink = $this->buildWayfInviteLink($wayfEndpoint, $token, $senderProvider); + $encoded = base64_encode("$token@$senderProvider"); + + $initiatorDisplayNameH = htmlspecialchars($initiatorDisplayName, ENT_QUOTES, 'UTF-8'); + $inviteLinkH = htmlspecialchars($inviteLink, ENT_QUOTES, 'UTF-8'); + $tokenSenderH = htmlspecialchars("$token@$senderProvider", ENT_QUOTES, 'UTF-8'); + $encodedH = htmlspecialchars($encoded, ENT_QUOTES, 'UTF-8'); + $messageH = nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), false); + + $header = $this->il10->t('Hi there,

%1$s invites you to exchange cloud accounts and contact information.
This will allow you to share data with each other.
', [$initiatorDisplayNameH]); + $messageSection = trim($message) === '' ? '' : "
---
$messageH
---
"; + $inviteLinkNote = $this->il10->t('
To accept this invite, click the link below and sign in with your cloud provider:

%1$s
', [$inviteLinkH]); + $technicalDetails = $this->il10->t('
Technical details (for advanced setups):
Invite code: %1$s
Encoded invite: %2$s
', [$tokenSenderH, $encodedH]); + + $htmlBody = "$header$messageSection$inviteLinkNote$technicalDetails"; + $email->setHtmlBody($htmlBody); + + $plainHeader = $this->il10->t('Hi there,', []) . "\n\n" . $this->il10->t('%1$s invites you to share contact information using your cloud account.', [$initiatorDisplayName]); + $plainMessageSection = trim($message) === '' ? '' : "\n---\n$message\n---\n"; + $plainInviteLinkNote = $this->il10->t('To accept this invite, click the link below and sign in with your cloud provider:', []) . "\n\n" . $inviteLink; + $plainTechnical = $this->il10->t('Technical details (for advanced setups):', []) . "\n" + . $this->il10->t('Invite code: %1$s', ["$token@$senderProvider"]) . "\n" + . $this->il10->t('Encoded invite: %1$s', [$encoded]); + $plainBody = "$plainHeader\n$plainMessageSection\n$plainInviteLinkNote\n\n$plainTechnical\n"; + $email->setPlainBody($plainBody); + + try { + /** @var string[] $failedRecipients */ + $failedRecipients = $this->mailer->send($email); + } catch (\Throwable $e) { + $this->logger->error("Mail transport failure while sending invite to '$address': " . $e->getMessage(), [ + 'app' => Application::APP_ID, + 'exception' => $e, + ]); + return new JSONResponse(['message' => "Could not send invite to '$address'"], Http::STATUS_BAD_GATEWAY); + } + + if (!empty($failedRecipients)) { + $this->logger->error("Could not send invite to '$address'", ['app' => Application::APP_ID]); + return new JSONResponse(['message' => "Could not send invite to '$address'"], Http::STATUS_BAD_GATEWAY); + } + + return new JSONResponse([], Http::STATUS_OK); + } + + private function normalizeProviderBase(string $provider): ?string { + $candidate = trim($provider); + if ($candidate === '') { + return null; + } + if (!preg_match('#^https?://#i', $candidate)) { + $candidate = 'https://' . $candidate; + } + + $parts = parse_url($candidate); + if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) { + return null; + } + + $scheme = strtolower((string)$parts['scheme']); + if (!in_array($scheme, ['http', 'https'], true)) { + return null; + } + + $host = strtolower((string)$parts['host']); + if ($host === '') { + return null; + } + if (!$this->federatedInvitesService->isSsrfGuardDisabled() && $this->isBlockedDiscoveryHost($host)) { + return null; + } + + $port = ''; + if (isset($parts['port'])) { + $portNumber = (int)$parts['port']; + if ($portNumber < 1 || $portNumber > 65535) { + return null; + } + $port = ':' . $portNumber; + } + + $path = ''; + if (isset($parts['path']) && is_string($parts['path']) && $parts['path'] !== '') { + $path = '/' . trim($parts['path'], '/'); + $path = rtrim($path, '/'); + } + + return $scheme . '://' . $host . $port . $path; + } + + private function isBlockedDiscoveryHost(string $host): bool { + $normalizedHost = strtolower(trim($host)); + if ($normalizedHost === 'localhost' || str_ends_with($normalizedHost, '.localhost')) { + return true; + } + + if (filter_var($normalizedHost, FILTER_VALIDATE_IP) === false) { + return false; + } + + return filter_var( + $normalizedHost, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE, + ) === false; + } + + private function buildInviteAcceptDialogAbsolute(string $base, string $dialog): ?string { + $trimmedDialog = trim($dialog); + if ($trimmedDialog === '') { + return null; + } + + $baseParts = parse_url($base); + if (!is_array($baseParts) || !isset($baseParts['scheme'], $baseParts['host'])) { + return null; + } + + if (preg_match('#^https?://#i', $trimmedDialog)) { + $dialogUrl = $this->normalizeProviderBase($trimmedDialog); + if ($dialogUrl === null) { + return null; + } + $dialogParts = parse_url($dialogUrl); + if (!is_array($dialogParts) || !isset($dialogParts['host'])) { + return null; + } + + $basePort = $baseParts['port'] ?? null; + $dialogPort = $dialogParts['port'] ?? null; + if (strtolower((string)$dialogParts['host']) !== strtolower((string)$baseParts['host']) || $basePort !== $dialogPort) { + return null; + } + + return $dialogUrl; + } + + $origin = $baseParts['scheme'] . '://' . $baseParts['host']; + if (isset($baseParts['port'])) { + $origin .= ':' . $baseParts['port']; + } + + if (str_starts_with($trimmedDialog, '/')) { + return $origin . $trimmedDialog; + } + + return rtrim($base, '/') . '/' . ltrim($trimmedDialog, '/'); + } + + private function buildWayfInviteLink(string $wayfEndpoint, string $token, string $senderProvider): string { + $separator = str_contains($wayfEndpoint, '?') ? '&' : '?'; + $query = http_build_query([ + 'token' => $token, + 'providerDomain' => $senderProvider, + ], '', '&', PHP_QUERY_RFC3986); + return $wayfEndpoint . $separator . $query; + } + + private function requireOcmInvitesEnabled(): ?JSONResponse { + if ($this->federatedInvitesService->isOcmInvitesEnabled()) { + return null; + } + + return new JSONResponse([ + 'code' => 'ocm_invites_disabled', + 'message' => $this->il10->t('OCM invites are disabled.'), + ], Http::STATUS_FORBIDDEN); + } + + private function cleanupSupersededInvitesForRecipient(string $uid, string $email): int { + if ($email === '') { + return 0; + } + + try { + return $this->federatedInviteMapper->deleteSupersededInvitesForRecipientEmail( + $uid, + $email, + $this->timeFactory->now()->getTimestamp(), + ); + } catch (Exception $e) { + $this->logger->warning('Could not clean up superseded invites for recipient email.', [ + 'app' => Application::APP_ID, + 'userId' => $uid, + 'email' => $email, + 'exception' => $e, + ]); + return 0; + } + } + + private function isInviteAccepted(FederatedInvite $invite): bool { + return $invite->isAccepted() === true || $invite->getAcceptedAt() !== null; + } + + private function isInviteExpired(FederatedInvite $invite): bool { + $expiredAt = $invite->getExpiredAt(); + return $expiredAt !== null && $expiredAt <= $this->timeFactory->now()->getTimestamp(); + } + + private function isDuplicateConstraintException(Throwable $e): bool { + $message = strtolower($e->getMessage()); + return str_contains($message, 'duplicate') + || str_contains($message, 'unique') + || str_contains($message, 'constraint'); + } +} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 6d7ce77fc0..f6fe993ee6 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -9,6 +9,7 @@ use OC\App\CompareVersion; use OCA\Contacts\AppInfo\Application; +use OCA\Contacts\Service\FederatedInvitesService; use OCA\Contacts\Service\GroupSharingService; use OCA\Contacts\Service\SocialApiService; use OCP\App\IAppManager; @@ -25,6 +26,7 @@ class PageController extends Controller { public function __construct( IRequest $request, + private FederatedInvitesService $federatedInvitesService, private IConfig $config, private IInitialState $initialState, private IFactory $languageFactory, @@ -67,6 +69,8 @@ public function index(): TemplateResponse { $isTalkEnabled = $this->appManager->isEnabledForUser('spreed') === true; $isTalkVersionCompatible = $this->compareVersion->isCompatible($talkVersion ? $talkVersion : '0.0.0', 2); + $isOcmInvitesEnabled = $this->federatedInvitesService->isOcmInvitesEnabled(); + $ocmInvitesConfig = $this->federatedInvitesService->getOcmInvitesConfig(); $this->initialState->provideInitialState('isGroupSharingEnabled', $isGroupSharingEnabled); $this->initialState->provideInitialState('locales', $locales); @@ -77,6 +81,8 @@ public function index(): TemplateResponse { $this->initialState->provideInitialState('isContactsInteractionEnabled', $isContactsInteractionEnabled); $this->initialState->provideInitialState('isCirclesEnabled', $isCirclesEnabled && $isCircleVersionCompatible); $this->initialState->provideInitialState('isTalkEnabled', $isTalkEnabled && $isTalkVersionCompatible); + $this->initialState->provideInitialState('isOcmInvitesEnabled', $isOcmInvitesEnabled); + $this->initialState->provideInitialState('ocmInvitesConfig', $ocmInvitesConfig); Util::addStyle(Application::APP_ID, 'contacts-main'); Util::addScript(Application::APP_ID, 'contacts-main'); diff --git a/lib/Cron/UpdateOcmProviders.php b/lib/Cron/UpdateOcmProviders.php new file mode 100644 index 0000000000..d20865450c --- /dev/null +++ b/lib/Cron/UpdateOcmProviders.php @@ -0,0 +1,37 @@ +final setInterval($this->expire_time); + } + + #[\Override] + protected function run($argument) { + $data = $this->wayfProvider->getMeshProviders(); + $data['expires'] = time() + $this->expire_time; + $this->appConfig->setValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, $data, true); + } +} diff --git a/lib/Db/FederatedInvite.php b/lib/Db/FederatedInvite.php new file mode 100644 index 0000000000..ad757ec7cf --- /dev/null +++ b/lib/Db/FederatedInvite.php @@ -0,0 +1,78 @@ +addType('accepted', Types::BOOLEAN); + $this->addType('acceptedAt', Types::BIGINT); + $this->addType('createdAt', Types::BIGINT); + $this->addType('expiredAt', Types::BIGINT); + $this->addType('recipientEmail', Types::STRING); + $this->addType('recipientName', Types::STRING); + $this->addType('recipientProvider', Types::STRING); + $this->addType('recipientUserId', Types::STRING); + $this->addType('token', Types::STRING); + $this->addType('userId', Types::STRING); + } + + public function jsonSerialize(): array { + return [ + 'accepted' => $this->accepted, + 'acceptedAt' => $this->acceptedAt, + 'createdAt' => $this->createdAt, + 'expiredAt' => $this->expiredAt, + 'recipientEmail' => $this->recipientEmail, + 'recipientName' => $this->recipientName, + 'recipientProvider' => $this->recipientProvider, + 'recipientUserId' => $this->recipientUserId, + 'token' => $this->token, + 'userId' => $this->userId, + ]; + } + +} diff --git a/lib/Db/FederatedInviteMapper.php b/lib/Db/FederatedInviteMapper.php new file mode 100644 index 0000000000..39e02025a1 --- /dev/null +++ b/lib/Db/FederatedInviteMapper.php @@ -0,0 +1,183 @@ + + */ +class FederatedInviteMapper extends QBMapper { + public const TABLE_NAME = 'federated_invites'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, self::TABLE_NAME); + } + + /** + * Returns the federated invite with the specified token + * + * @return FederatedInvite + */ + public function findByToken(string $token): FederatedInvite { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from(self::TABLE_NAME) + ->where($qb->expr()->eq('token', $qb->createNamedParameter($token))); + return $this->findEntity($qb); + } + + /** + * Returns all open federated invites for the user with the specified user id + * + * @return list + */ + public function findOpenInvitesByUid(string $userId, ?int $now = null): array { + $timestamp = $now ?? time(); + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from(self::TABLE_NAME) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNull('accepted_at')) + ->andWhere( + $qb->expr()->orX( + $qb->expr()->isNull('expired_at'), + $qb->expr()->gt('expired_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)), + ), + ); + return $this->findEntities($qb); + } + + /** + * Returns all open federated invites for the user with the specified user id and for the specified recipient email + * + * @return list + */ + public function findOpenInvitesByRecipientEmail(string $userId, string $email, ?int $now = null): array { + $timestamp = $now ?? time(); + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from(self::TABLE_NAME) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('recipient_email', $qb->createNamedParameter($email))) + ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNull('accepted_at')) + ->andWhere( + $qb->expr()->orX( + $qb->expr()->isNull('expired_at'), + $qb->expr()->gt('expired_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)), + ), + ); + return $this->findEntities($qb); + } + + /** + * Returns the federated invite with the specified token for the user with the specified user id. + * + * @return FederatedInvite the matching invite + */ + public function findInviteByTokenAndUid(string $token, string $userId):FederatedInvite { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from(self::TABLE_NAME) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR))); + return $this->findEntity($qb); + } + + /** + * Atomically claims an unclaimed (recipient_email IS NULL) and unaccepted + * invite for the given email and refreshes its lifetime. + * + * Returns true when exactly one row was affected, meaning the caller now + * owns the (token, recipient_email) pair. Returns false when the row no + * longer matches the precondition (a concurrent attach already claimed + * the invite, the invite was accepted, or the row vanished). + */ + public function claimInviteForEmail( + string $token, + string $userId, + string $email, + int $createdAt, + int $expiredAt, + ): bool { + $qb = $this->db->getQueryBuilder(); + $qb->update(self::TABLE_NAME) + ->set('recipient_email', $qb->createNamedParameter($email)) + ->set('created_at', $qb->createNamedParameter($createdAt, IQueryBuilder::PARAM_INT)) + ->set('expired_at', $qb->createNamedParameter($expiredAt, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->isNull('recipient_email')) + ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNull('accepted_at')); + return $qb->executeStatement() === 1; + } + + /** + * Best-effort revert of a previous claim made by claimInviteForEmail(). + * Scoped to the same sender (user_id) and only takes effect when the row + * still has the email we set and is still unaccepted, so the revert + * cannot undo a successful accept and cannot run if a concurrent attach + * changed recipient_email between the claim and the revert. + * + * Returns true when the revert took effect (exactly one row updated). + */ + public function revertInviteEmail( + string $token, + string $userId, + string $email, + int $previousCreatedAt, + ?int $previousExpiredAt, + ): bool { + $qb = $this->db->getQueryBuilder(); + $expiredParam = $previousExpiredAt === null + ? $qb->createNamedParameter(null, IQueryBuilder::PARAM_NULL) + : $qb->createNamedParameter($previousExpiredAt, IQueryBuilder::PARAM_INT); + $qb->update(self::TABLE_NAME) + ->set('recipient_email', $qb->createNamedParameter(null, IQueryBuilder::PARAM_NULL)) + ->set('created_at', $qb->createNamedParameter($previousCreatedAt, IQueryBuilder::PARAM_INT)) + ->set('expired_at', $expiredParam) + ->where($qb->expr()->eq('token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('recipient_email', $qb->createNamedParameter($email))) + ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNull('accepted_at')); + return $qb->executeStatement() === 1; + } + + /** + * Deletes invites that can no longer be acted on but would still block a + * fresh invite for the same recipient email. This covers expired rows and + * defensive cleanup for rows that already have an accepted_at timestamp. + */ + public function deleteSupersededInvitesForRecipientEmail(string $userId, string $email, ?int $now = null): int { + $timestamp = $now ?? time(); + $qb = $this->db->getQueryBuilder(); + $qb->delete(self::TABLE_NAME) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('recipient_email', $qb->createNamedParameter($email))) + ->andWhere( + $qb->expr()->orX( + $qb->expr()->isNotNull('accepted_at'), + $qb->expr()->andX( + $qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)), + $qb->expr()->isNotNull('expired_at'), + $qb->expr()->lte('expired_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)), + ), + ), + ); + return $qb->executeStatement(); + } + +} diff --git a/lib/Exception/ContactExistsException.php b/lib/Exception/ContactExistsException.php new file mode 100644 index 0000000000..98b7fd0ad9 --- /dev/null +++ b/lib/Exception/ContactExistsException.php @@ -0,0 +1,15 @@ + + */ +class FederatedInviteAcceptedListener implements IEventListener { + + public function __construct( + private FederatedInvitesService $federatedInvitesService, + private LoggerInterface $logger, + ) { + } + + /** + * Handles the OCMEndpointRequestEvent that is dispatched by the + * OCMRequestController as response to an OCM request. This handler manages + * the invite-accepted capability. + */ + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof OCMEndpointRequestEvent) + || $event->getRequestedCapability() !== 'invite-accepted') { + return; + } + + $payload = $event->getPayload(); + if (!$this->isValidInviteAcceptedPayload($payload)) { + $this->logger->error('Could not accept invite, user data is incomplete.', [ + 'app' => Application::APP_ID, + 'payloadKeys' => array_keys($payload), + ]); + $event->setResponse(new JSONResponse([ + 'message' => 'Could not accept invite, user data is incomplete.', + ], Http::STATUS_NOT_FOUND)); + return; + } + + $event->setResponse($this->federatedInvitesService->inviteAccepted( + $payload['recipientProvider'], + $payload['token'], + $payload['userID'], + $payload['email'], + $payload['name'], + )); + } + + /** + * The accepted-invite callback requires all documented OCM string fields to + * be present and non-empty. + * + * @param array $payload + */ + private function isValidInviteAcceptedPayload(array $payload): bool { + foreach (['recipientProvider', 'token', 'userID', 'email', 'name'] as $key) { + if (!array_key_exists($key, $payload) || !is_string($payload[$key])) { + return false; + } + } + + return trim($payload['recipientProvider']) !== '' + && trim($payload['token']) !== '' + && trim($payload['userID']) !== '' + && trim($payload['email']) !== '' + && trim($payload['name']) !== ''; + } +} diff --git a/lib/Listener/OcmDiscoveryListener.php b/lib/Listener/OcmDiscoveryListener.php new file mode 100644 index 0000000000..722ea518a8 --- /dev/null +++ b/lib/Listener/OcmDiscoveryListener.php @@ -0,0 +1,69 @@ + */ +class OcmDiscoveryListener implements IEventListener { + public function __construct( + private IAppConfig $appConfig, + private IURLGenerator $urlGenerator, + private LoggerInterface $logger, + ) { + } + + /** + * Register the invite capability and dialog route on local OCM discovery. + * + * @param Event $event an event of type LocalOCMDiscoveryEvent or ResourceTypeRegisterEvent + * @return void + */ + #[\Override] + public function handle(Event $event): void { + if (!$this->isOcmDiscoveryEvent($event)) { + return; + } + + if (!$this->appConfig->getValueBool(Application::APP_ID, ContactsConfigLexicon::OCM_INVITES_ENABLED)) { + return; + } + + if ($event instanceof LocalOCMDiscoveryEvent) { + $event->addCapability('invite-accepted'); + + try { + $event->getProvider()->setInviteAcceptDialog( + $this->urlGenerator->linkToRouteAbsolute(FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE_NAME), + ); + } catch (Throwable $e) { + $this->logger->warning('OCM invites are enabled but invite accept dialog route cannot be resolved', [ + 'app' => Application::APP_ID, + 'route' => FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE_NAME, + 'exception' => $e, + ]); + } + } + } + + private function isOcmDiscoveryEvent(Event $event): bool { + return $event instanceof LocalOCMDiscoveryEvent + || $event instanceof ResourceTypeRegisterEvent; + } +} diff --git a/lib/Migration/Version8004Date20260130131217.php b/lib/Migration/Version8004Date20260130131217.php new file mode 100644 index 0000000000..709e78761a --- /dev/null +++ b/lib/Migration/Version8004Date20260130131217.php @@ -0,0 +1,92 @@ +hasTable($table_name)) { + $table = $schema->createTable($table_name); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 11, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + + ]); + // https://saturncloud.io/blog/what-is-the-maximum-length-of-a-url-in-different-browsers/#maximum-url-length-in-different-browsers + // We use the least common denominator, the minimum length supported by browsers + $table->addColumn('recipient_provider', Types::STRING, [ + 'notnull' => false, + 'length' => 2083, + ]); + $table->addColumn('recipient_user_id', Types::STRING, [ + 'notnull' => false, + 'length' => 1024, + ]); + $table->addColumn('recipient_name', Types::STRING, [ + 'notnull' => false, + 'length' => 1024, + ]); + // https://www.directedignorance.com/blog/maximum-length-of-email-address + $table->addColumn('recipient_email', Types::STRING, [ + 'notnull' => false, + 'length' => 320, + ]); + $table->addColumn('token', Types::STRING, [ + 'notnull' => true, + 'length' => 60, + ]); + $table->addColumn('accepted', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false + ]); + $table->addColumn('created_at', Types::BIGINT, [ + 'notnull' => true, + ]); + + $table->addColumn('expired_at', Types::BIGINT, [ + 'notnull' => false, + ]); + + $table->addColumn('accepted_at', Types::BIGINT, [ + 'notnull' => false, + ]); + + $table->addUniqueConstraint(['token']); + $table->setPrimaryKey(['id']); + return $schema; + } + + return null; + } +} diff --git a/lib/Migration/Version8005Date20260418120000.php b/lib/Migration/Version8005Date20260418120000.php new file mode 100644 index 0000000000..18a6577ca5 --- /dev/null +++ b/lib/Migration/Version8005Date20260418120000.php @@ -0,0 +1,127 @@ +connection->getQueryBuilder(); + $qb->update('federated_invites') + ->set('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)) + ->where($qb->expr()->isNull('accepted')); + $qb->executeStatement(); + } + + #[\Override] + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('federated_invites')) { + return null; + } + + $table = $schema->getTable('federated_invites'); + if (!$table->hasColumn('accepted')) { + return null; + } + + $column = $table->getColumn('accepted'); + if ($column->getNotnull() === true) { + return null; + } + + $column->setNotnull(true); + $column->setDefault(false); + + return $schema; + } + + #[\Override] + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $this->createPartialUniqueIndex($output); + } + + private function createPartialUniqueIndex(IOutput $output): void { + $provider = $this->connection->getDatabaseProvider(); + if ($provider !== IDBConnection::PLATFORM_POSTGRES && $provider !== IDBConnection::PLATFORM_SQLITE) { + $output->info(sprintf( + 'Skipping partial unique index %s on %s; application-level guard remains in effect.', + self::INDEX_NAME, + $provider, + )); + $this->logger->info( + 'Skipped partial unique index for federated_invites on database provider {provider}.', + ['app' => 'contacts', 'provider' => $provider], + ); + return; + } + + $predicate = $provider === IDBConnection::PLATFORM_POSTGRES + ? 'recipient_email IS NOT NULL AND accepted = false' + : 'recipient_email IS NOT NULL AND accepted = 0'; + + $sql = sprintf( + 'CREATE UNIQUE INDEX IF NOT EXISTS %s ON %sfederated_invites (user_id, recipient_email) WHERE %s', + self::INDEX_NAME, + '*PREFIX*', + $predicate, + ); + + try { + $this->connection->executeStatement($sql); + } catch (\Throwable $e) { + $this->logger->warning( + 'Failed to create partial unique index for federated_invites: {message}', + ['app' => 'contacts', 'message' => $e->getMessage(), 'exception' => $e], + ); + throw new \RuntimeException('Could not create required open-invite uniqueness index.', 0, $e); + } + } + +} diff --git a/lib/Service/FederatedInvitesService.php b/lib/Service/FederatedInvitesService.php new file mode 100644 index 0000000000..bb4a12503c --- /dev/null +++ b/lib/Service/FederatedInvitesService.php @@ -0,0 +1,257 @@ +appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_ENABLED); + } + + public function isOptionalMailEnabled(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_OPTIONAL_MAIL); + } + + public function isCcSenderEnabled(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_CC_SENDER); + } + + public function isEncodedCopyButtonEnabled(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_ENCODED_COPY_BUTTON); + } + + public function isSsrfGuardDisabled(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_DISABLE_SSRF_GUARD); + } + + /** + * The set of admin-toggleable OCM bool keys. Used to gate writes from the + * admin settings page so callers cannot persist arbitrary keys. + */ + public const OCM_INVITES_BOOL_KEYS = [ + ConfigLexicon::OCM_INVITES_OPTIONAL_MAIL, + ConfigLexicon::OCM_INVITES_CC_SENDER, + ConfigLexicon::OCM_INVITES_ENCODED_COPY_BUTTON, + ConfigLexicon::OCM_INVITES_DISABLE_SSRF_GUARD, + ]; + + /** + * Persist an OCM admin bool toggle. Returns true when the key is allowed. + */ + public function setOcmInviteBoolSetting(string $key, bool $value): bool { + if (!in_array($key, self::OCM_INVITES_BOOL_KEYS, true)) { + return false; + } + $this->appConfig->setValueBool(Application::APP_ID, $key, $value); + return true; + } + + /** + * Returns all OCM invites config flags for frontend consumption + */ + public function getOcmInvitesConfig(): array { + return [ + 'optionalMail' => $this->isOptionalMailEnabled(), + 'ccSender' => $this->isCcSenderEnabled(), + 'encodedCopyButton' => $this->isEncodedCopyButtonEnabled(), + ]; + } + + /** + * Returns the provider's server FQDN. + * @return string the FQDN + */ + public function getProviderFQDN(): string { + $serverUrl = $this->urlGenerator->getAbsoluteURL('/'); + $parts = parse_url($serverUrl); + if (!is_array($parts) || !isset($parts['host']) || !is_string($parts['host'])) { + return ''; + } + return $parts['host']; + } + + /** + * Returns the expiration date. + * @param int $creationDate + * @return int the expiration date + */ + public function getInviteExpirationDate(int $creationDate): int { + return $creationDate + self::INVITE_EXPIRATION_PERIOD_SECONDS; + } + + /** + * Creates a new contact and adds it to the address book of the user with the specified userId or, + * if null, the current logged-in user. + * + * @param string cloudId + * @param string email + * @param string name + * @param ?string userId id of the user for which to create the new contact. + * If null, this is the current logged-in user. + * + * @return string the ref of the new contact in the form + * 'contactURI~addressBookUri' + * @throws ContactExistsException + */ + public function createNewContact(string $cloudId, string $email, string $name, ?string $userId): ?string { + $localUserId = $userId ? $userId : $this->userSession->getUser()->getUID(); + $newContact = $this->socialApiService->createContact( + $cloudId, + $email, + $name, + $localUserId, + ); + if (!isset($newContact)) { + $this->logger->error('Error creating contact for user {userId} with cloud id {cloudId}.', [ + 'app' => Application::APP_ID, + 'userId' => $localUserId, + 'cloudId' => $cloudId, + ]); + return null; + } + $this->logger->info('Created new contact with UID: ' . $newContact['UID'] . ' for user with UID: ' . $localUserId, ['app' => Application::APP_ID]); + $addressBookUri = CardDavBackend::PERSONAL_ADDRESSBOOK_URI; + if (isset($newContact['ADDRESSBOOK_URI']) && is_string($newContact['ADDRESSBOOK_URI']) && $newContact['ADDRESSBOOK_URI'] !== '') { + $addressBookUri = $newContact['ADDRESSBOOK_URI']; + } + $contactRef = $newContact['UID'] . '~' . $addressBookUri; + return $contactRef; + } + + /** + * This is the invite-accepted capability implementation. + */ + public function inviteAccepted(string $recipientProvider, string $token, string $userID, string $email, string $name): JSONResponse { + $this->logger->debug('Processing share invitation for ' . $userID . ' with token ' . $token . ' and email ' . $email . ' and name ' . $name); + + $updated = $this->timeFactory->getTime(); + + if ($token === '') { + $response = new JSONResponse(['message' => 'Invalid or non existing token', 'error' => true], Http::STATUS_BAD_REQUEST); + $response->throttle(); + return $response; + } + + if (trim($recipientProvider) === '' || trim($userID) === '' || trim($email) === '' || trim($name) === '') { + return new JSONResponse(['message' => 'Could not accept invite, user data is incomplete.', 'error' => true], Http::STATUS_BAD_REQUEST); + } + + try { + $invitation = $this->federatedInviteMapper->findByToken($token); + } catch (DoesNotExistException) { + $response = ['message' => 'Invalid or non existing token', 'error' => true]; + $status = Http::STATUS_BAD_REQUEST; + $response = new JSONResponse($response, $status); + $response->throttle(); + return $response; + } + + if ($invitation->isAccepted() === true) { + $response = ['message' => 'Invite already accepted', 'error' => true]; + $status = Http::STATUS_CONFLICT; + return new JSONResponse($response, $status); + } + + if ($invitation->getExpiredAt() !== null && $updated > $invitation->getExpiredAt()) { + $response = ['message' => 'Invitation expired', 'error' => true]; + $status = Http::STATUS_BAD_REQUEST; + return new JSONResponse($response, $status); + } + // Note that there is no user session; local user is the sender of the invite + $localUser = $this->userManager->get($invitation->getUserId()); + if ($localUser === null) { + $response = ['message' => 'Invalid or non existing token', 'error' => true]; + $status = Http::STATUS_BAD_REQUEST; + $response = new JSONResponse($response, $status); + $response->throttle(); + return $response; + } + + $sharedFromEmail = $localUser->getEMailAddress(); + if ($sharedFromEmail === null) { + $response = ['message' => 'Invalid or non existing token', 'error' => true]; + $status = Http::STATUS_BAD_REQUEST; + $response = new JSONResponse($response, $status); + $response->throttle(); + return $response; + } + $sharedFromDisplayName = $localUser->getDisplayName(); + + $response = ['userID' => $localUser->getUID(), 'email' => $sharedFromEmail, 'name' => $sharedFromDisplayName]; + $status = Http::STATUS_OK; + + $cloudId = $userID . '@' . $this->addressHandler->removeProtocolFromUrl($recipientProvider); + try { + $contactRef = $this->createNewContact( + $cloudId, + $email, + $name, + $localUser->getUID() + ); + if ($contactRef === null) { + $this->logger->error('Could not create sender-side contact after invite acceptance.', [ + 'app' => Application::APP_ID, + 'userId' => $localUser->getUID(), + 'cloudId' => $cloudId, + 'token' => $token, + ]); + return new JSONResponse([ + 'message' => 'Could not create sender-side contact after invite acceptance.', + 'error' => true, + ], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } catch (ContactExistsException $e) { + // A duplicate sender-side contact should not block invite acceptance. + $this->logger->info("Contact with cloud id $cloudId already exists. "); + } + + $invitation->setAccepted(true); + $invitation->setRecipientEmail($email); + $invitation->setRecipientName($name); + $invitation->setRecipientProvider($recipientProvider); + $invitation->setRecipientUserId($userID); + $invitation->setAcceptedAt($updated); + $invitation = $this->federatedInviteMapper->update($invitation); + return new JSONResponse($response, $status); + } +} diff --git a/lib/Service/SocialApiService.php b/lib/Service/SocialApiService.php index ac9cbe6342..be9aee41d9 100644 --- a/lib/Service/SocialApiService.php +++ b/lib/Service/SocialApiService.php @@ -9,8 +9,11 @@ namespace OCA\Contacts\Service; +use Exception; use OCA\Contacts\AppInfo\Application; +use OCA\Contacts\Exception\ContactExistsException; use OCA\Contacts\Service\Social\CompositeSocialProvider; +use OCA\DAV\CardDAV\CardDavBackend; use OCA\DAV\CardDAV\ContactsManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; @@ -20,9 +23,11 @@ use OCP\Http\Client\IClientService; use OCP\IAddressBook; use OCP\IConfig; +use OCP\ICreateContactFromString; use OCP\IL10N; use OCP\IURLGenerator; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; use function in_array; class SocialApiService { @@ -44,6 +49,7 @@ public function __construct( private IURLGenerator $urlGen, private ITimeFactory $timeFactory, private ImageResizer $imageResizer, + private LoggerInterface $logger, ) { $this->appName = Application::APP_ID; } @@ -94,30 +100,22 @@ protected function addPhoto(array &$contact, string $imageType, string $photo) { /** * Gets the addressbook of an addressbookId * - * @param string $addressBookId the identifier of the addressbook + * @param string $addressbookId the identifier of the addressbook * @param IManager|null $manager optional a ContactManager to use * * @return IAddressBook|null the corresponding addressbook or null */ - protected function getAddressBook(string $addressBookId, ?IManager $manager = null) : ?IAddressBook { + protected function getAddressBook(string $addressbookId, ?IManager $manager = null) : ?IAddressBook { $addressBook = null; if ($manager === null) { $manager = $this->manager; } $addressBooks = $manager->getUserAddressBooks(); foreach ($addressBooks as $ab) { - if ($ab->getUri() === $addressBookId) { + if ($ab->getUri() === $addressbookId) { $addressBook = $ab; } } - - $addressBookIsUpdatable = $addressBook !== null - && ($addressBook->getPermissions() & Constants::PERMISSION_UPDATE); - - if (!$addressBookIsUpdatable) { - return null; - } - return $addressBook; } @@ -166,7 +164,11 @@ public function updateContact(string $addressbookId, string $contactId, ?string $contact = $contacts[0]; if ($network) { - $allConnectors = [$this->socialProvider->getSocialConnector($network)]; + $connector = $this->socialProvider->getSocialConnector($network); + if ($connector === null) { + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } + $allConnectors = [$connector]; } $connectors = array_filter($allConnectors, function ($connector) use ($contact) { @@ -191,7 +193,10 @@ public function updateContact(string $addressbookId, string $contactId, ?string try { $httpResult = $this->clientService->newClient()->get($url); $socialdata = $httpResult->getBody(); - $imageType = $httpResult->getHeader('content-type'); + $imageTypeHeader = $httpResult->getHeader('content-type'); + if (is_string($imageTypeHeader) && $imageTypeHeader !== '') { + $imageType = strtolower(trim(explode(';', $imageTypeHeader, 2)[0])); + } if (isset($socialdata) && !empty($imageType)) { break; } @@ -234,6 +239,130 @@ public function updateContact(string $addressbookId, string $contactId, ?string return new JSONResponse([], Http::STATUS_OK); } + /** + * Creates a contact and adds it to the address book of the local user with the specified userId, + * unless a contact with the specified cloudId already exists for that local user. + * + * @param {string} cloudId the cloud id of the contact + * @param {string} email the email of the contact + * @param {string} name the name of the contact + * @param {string} userId the uid of the local user + * @throws ContactExistsException + */ + public function createContact(string $cloudId, string $email, string $name, string $userId): ?array { + try { + // Set up the contacts provider for the user with the specified uid + $cm = $this->serverContainer->get(ContactsManager::class); + $cm->setupContactsProvider($this->manager, $userId, $this->urlGen); + + // if contact already exists we throw ContactExistsException + $searchResult = $this->manager->search($cloudId, ['CLOUD']); + if (count($searchResult) > 0) { + $this->logger->info('Contact with cloud id ' . $cloudId . ' already exists.', ['app' => Application::APP_ID]); + throw new ContactExistsException('Contact with cloud id ' . $cloudId . ' already exists.'); + } + + $addressBook = $this->pickAddressBookForContactCreation($this->manager->getUserAddressBooks()); + if (!isset($addressBook)) { + $this->logger->error('No suitable address book found. Unable to add the new contact on invite accepted.', ['app' => Application::APP_ID]); + return null; + } + + $newContact = $this->manager->createOrUpdate( + [ + 'FN' => $name, + 'EMAIL' => $email, + 'CLOUD' => $cloudId, + ], + $addressBook->getKey() + ); + $newContact['ADDRESSBOOK_URI'] = $addressBook->getUri(); + return $newContact; + } catch (ContactExistsException $e) { + throw $e; + } catch (Exception $e) { + $this->logger->error('An exception occurred creating a new contact: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]); + } + return null; + } + + /** + * Creates a federated contact (no thrown exceptions; null on duplicate or + * when no suitable writable address book exists). + * + * Used by the FederatedInviteAcceptedListener on the inviter side, where + * there is no user session and the inviter UID must be passed explicitly. + * + * @param string $cloudId the cloud id of the federated contact + * @param string $email the email of the federated contact + * @param string $name the display name of the federated contact + * @param string $userId the uid of the local (inviter) user + * + * @return array|null the created contact array, or null if a contact with + * that cloud id already exists or there is no suitable + * writable address book for the inviter + */ + public function createFederatedContact(string $cloudId, string $email, string $name, string $userId): ?array { + try { + $cm = $this->serverContainer->get(ContactsManager::class); + $cm->setupContactsProvider($this->manager, $userId, $this->urlGen); + + $searchResult = $this->manager->search($cloudId, ['CLOUD']); + if (count($searchResult) > 0) { + $this->logger->info('Contact with cloud id ' . $cloudId . ' already exists.', ['app' => Application::APP_ID]); + return null; + } + + $addressBook = $this->pickAddressBookForContactCreation($this->manager->getUserAddressBooks()); + if (!isset($addressBook)) { + $this->logger->error('No suitable address book found. Unable to add the new contact on invite accepted.', ['app' => Application::APP_ID]); + return null; + } + + $newContact = $this->manager->createOrUpdate( + [ + 'FN' => $name, + 'EMAIL' => $email, + 'CLOUD' => $cloudId, + ], + $addressBook->getKey() + ); + return $newContact; + } catch (Exception $e) { + $this->logger->error('An exception occurred creating a federated contact: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]); + } + return null; + } + + /** + * Pick a destination book using the same order as ImportController: + * personal address book first, then first writable non-shared. + */ + private function pickAddressBookForContactCreation(array $addressBooks): ?IAddressBook { + $creatableAddressBooks = array_filter( + $addressBooks, + static fn (IAddressBook $addressBook): bool => $addressBook instanceof ICreateContactFromString, + ); + + foreach ($creatableAddressBooks as $addressBook) { + if ($addressBook->getUri() === CardDavBackend::PERSONAL_ADDRESSBOOK_URI) { + return $addressBook; + } + } + + foreach ($creatableAddressBooks as $addressBook) { + if ($addressBook->isShared()) { + continue; + } + if (($addressBook->getPermissions() & Constants::PERMISSION_CREATE) === 0) { + continue; + } + return $addressBook; + } + + return null; + } + /** * checks an addressbook is existing * diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index efc2a15290..046901d8eb 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -8,6 +8,7 @@ namespace OCA\Contacts\Settings; use OCA\Contacts\AppInfo\Application; +use OCA\Contacts\Service\FederatedInvitesService; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IConfig; @@ -16,28 +17,24 @@ class AdminSettings implements ISettings { protected $appName; - /** - * Admin constructor. - * - * @param IConfig $config - * @param IL10N $l - */ public function __construct( private IConfig $config, private IInitialState $initialState, + private FederatedInvitesService $federatedInvitesService, ) { $this->appName = Application::APP_ID; } - /** - * @return TemplateResponse - */ #[\Override] - public function getForm() { + public function getForm(): TemplateResponse { foreach (Application::AVAIL_SETTINGS as $key => $default) { $data = $this->config->getAppValue($this->appName, $key, $default); $this->initialState->provideInitialState($key, $data); } + $this->initialState->provideInitialState( + 'ocmInvitesConfig', + $this->federatedInvitesService->getOcmInvitesConfig(), + ); return new TemplateResponse($this->appName, 'settings/admin'); } diff --git a/lib/WayfProvider.php b/lib/WayfProvider.php new file mode 100644 index 0000000000..0da1a50c9e --- /dev/null +++ b/lib/WayfProvider.php @@ -0,0 +1,148 @@ +appConfig->getValueString(Application::APP_ID, ConfigLexicon::MESH_PROVIDERS_SERVICE))); + $federations = []; + $ourServerUrlParts = parse_url($this->urlGenerator->getAbsoluteUrl('/')); + $ourFqdn = is_array($ourServerUrlParts) && isset($ourServerUrlParts['host']) ? (string)$ourServerUrlParts['host'] : ''; + + $found = []; + foreach ($urls as $url) { + if ($url === '') { + continue; + } + try { + $res = $this->httpClient->newClient()->get($url); + $code = $res->getStatusCode(); + if (!($code >= 200 && $code < 400)) { + continue; + } + $data = json_decode($res->getBody(), true); + $fed = $data['federation'] ?? 'Unknown'; + $federations[$fed] = $federations[$fed] ?? []; + + $servers = is_array($data['servers'] ?? null) ? $data['servers'] : []; + foreach ($servers as $prov) { + $providerUrl = is_array($prov) && isset($prov['url']) ? (string)$prov['url'] : ''; + if ($providerUrl === '') { + continue; + } + $fqdn = parse_url($providerUrl, PHP_URL_HOST); + if (!is_string($fqdn) || $fqdn === '') { + continue; + } + if (($ourFqdn !== '' && $ourFqdn === $fqdn) || in_array($fqdn, $found, true)) { + continue; + } + try { + $disc = $this->discovery->discover($providerUrl, true); + $inviteAcceptDialog = $disc->getInviteAcceptDialog(); + } catch (Exception $e) { + $this->logger->error('Discovery failed for ' . $providerUrl . ': ' . $e->getMessage(), ['app' => Application::APP_ID]); + continue; + } + if ($inviteAcceptDialog === '') { + // We fall back on Nextcloud default path + $inviteAcceptDialogPath = self::getInviteAcceptDialogPath(); + $inviteAcceptDialog = rtrim($providerUrl, '/') . $inviteAcceptDialogPath; + } + $federations[$fed][] = [ + 'provider' => $disc->getProvider(), + 'name' => (string)($prov['displayName'] ?? $fqdn), + 'fqdn' => $fqdn, + 'inviteAcceptDialog' => $inviteAcceptDialog, + ]; + array_push($found, $fqdn); + } + usort($federations[$fed], fn ($a, $b) => strcmp($a['name'], $b['name'])); + } catch (Exception $e) { + $this->logger->error('Fetch failed for ' . $url . ': ' . $e->getMessage(), ['app' => Application::APP_ID]); + } + } + return $federations; + } + + /** + * Returns all mesh providers from cache if possible. + * + * @return array an array containing all mesh providers + */ + public function getMeshProvidersFromCache(): array { + $data = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, [], true); + $expires = is_array($data) && array_key_exists('expires', $data) ? (int)$data['expires'] : 0; + if (is_array($data) && $expires > time()) { + $this->logger->debug('Cache hit, expires at: ' . $expires, ['app' => Application::APP_ID]); + unset($data['expires']); + return $data; + } + + $this->logger->debug('Cache miss or expired: cron job should update providers.', ['app' => Application::APP_ID]); + return $this->getMeshProviders(); + } + + /** + * Returns the WAYF (Where Are You From) login page endpoint to be used in the invitation link. + * Can be read from the app config key in ConfigLexicon::WAYF_ENDPOINT. + * If not set the endpoint the WAYF page implementation of this app is returned. + * Note that the invitation link still needs the token and provider parameters, eg. "https://?token=$token&provider=$provider" + * + * Security: the value of ConfigLexicon::WAYF_ENDPOINT is used as the base of every + * outgoing invitation URL. It is administrator-only configuration and + * must point to a trusted WAYF page that the recipient can safely visit. + * Setting it to an attacker-controlled origin would let invite links + * leak the token and provider query parameters to a third party. + * + * @return string|null the WAYF login page endpoint or null if it could not be created + */ + public function getWayfEndpoint(): ?string { + // default wayf endpoint + $defaultWayfEndpoint = $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.federatedinvites.wayf'); + $configuredEndpoint = trim($this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::WAYF_ENDPOINT)); + return $configuredEndpoint === '' ? $defaultWayfEndpoint : $configuredEndpoint; + } + + /** + * Returns the path of the invite accept dialog route. + * + * @return string + */ + public function getInviteAcceptDialogPath(): string { + return $this->urlGenerator->linkToRoute(Application::APP_ID . '.federatedinvites.inviteacceptdialog'); + } +} diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 0d79be5c7f..ad6b88c377 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -12,28 +12,76 @@ v-model="allowSocialSync" type="checkbox" class="checkbox" - @change="updateSetting('allowSocialSync')"> + @change="updateSocialSetting('allowSocialSync')">

+ +

{{ t('contacts', 'OCM invites') }}

+

+ + +

+

+ + +

+

+ + +

+ + diff --git a/src/components/AppNavigation/RootNavigation.vue b/src/components/AppNavigation/RootNavigation.vue index de60ab6ca3..3bbc08604b 100644 --- a/src/components/AppNavigation/RootNavigation.vue +++ b/src/components/AppNavigation/RootNavigation.vue @@ -94,6 +94,26 @@ + + + + + + this.contacts[contact.key]?.groups && this.contacts[contact.key]?.groups?.length === 0) @@ -422,7 +453,7 @@ export default { : t('contacts', 'Collapse teams') }, - ...mapStores(useUserGroupStore), + ...mapStores(useOcmInvitesStore, useUserGroupStore), }, methods: { diff --git a/src/components/Ocm/OcmAcceptForm.vue b/src/components/Ocm/OcmAcceptForm.vue new file mode 100644 index 0000000000..2ba4646890 --- /dev/null +++ b/src/components/Ocm/OcmAcceptForm.vue @@ -0,0 +1,195 @@ + + + + + + + diff --git a/src/components/Ocm/OcmAttachEmailForm.vue b/src/components/Ocm/OcmAttachEmailForm.vue new file mode 100644 index 0000000000..21c981c4ad --- /dev/null +++ b/src/components/Ocm/OcmAttachEmailForm.vue @@ -0,0 +1,150 @@ + + + + + + + diff --git a/src/components/Ocm/OcmInviteAccept.vue b/src/components/Ocm/OcmInviteAccept.vue new file mode 100644 index 0000000000..f00649e13e --- /dev/null +++ b/src/components/Ocm/OcmInviteAccept.vue @@ -0,0 +1,100 @@ + + + + + + + diff --git a/src/components/Ocm/OcmInviteDetails.vue b/src/components/Ocm/OcmInviteDetails.vue new file mode 100644 index 0000000000..0628b48b1c --- /dev/null +++ b/src/components/Ocm/OcmInviteDetails.vue @@ -0,0 +1,453 @@ + + + + + + + diff --git a/src/components/Ocm/OcmInviteForm.vue b/src/components/Ocm/OcmInviteForm.vue new file mode 100644 index 0000000000..3f3070b5d1 --- /dev/null +++ b/src/components/Ocm/OcmInviteForm.vue @@ -0,0 +1,256 @@ + + + + + + + diff --git a/src/components/Ocm/OcmInviteShareActions.vue b/src/components/Ocm/OcmInviteShareActions.vue new file mode 100644 index 0000000000..ef57d7306b --- /dev/null +++ b/src/components/Ocm/OcmInviteShareActions.vue @@ -0,0 +1,92 @@ + + + + + + + diff --git a/src/components/Ocm/OcmInvitesList.vue b/src/components/Ocm/OcmInvitesList.vue new file mode 100644 index 0000000000..183a7f7813 --- /dev/null +++ b/src/components/Ocm/OcmInvitesList.vue @@ -0,0 +1,169 @@ + + + + + + + diff --git a/src/components/Ocm/OcmInvitesListItem.vue b/src/components/Ocm/OcmInvitesListItem.vue new file mode 100644 index 0000000000..a0e8d836bd --- /dev/null +++ b/src/components/Ocm/OcmInvitesListItem.vue @@ -0,0 +1,95 @@ + + + + + + + + diff --git a/src/components/Ocm/Wayf.vue b/src/components/Ocm/Wayf.vue new file mode 100644 index 0000000000..54f930f6d6 --- /dev/null +++ b/src/components/Ocm/Wayf.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/src/css/wayf.scss b/src/css/wayf.scss new file mode 100755 index 0000000000..280418b04a --- /dev/null +++ b/src/css/wayf.scss @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +#contacts-wayf { + background: var(--color-background-plain); + color: var(--color-background-plain-text); + border-radius: 8px; +} + +.wayf-list { + text-align: start; + margin: 0; + padding: 0; + overflow: hidden; +} + +.wayf-list > li { + align-items: center; + justify-content: space-between; + gap: 0.5rem; + + padding: 0.75rem 1rem; + margin: 0.25rem 0; + + background-color: var(--color-background-dark); + color: var(--color-main-text); + + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s; +} + +.wayf-list > li:hover { + background-color: var(--color-background-darker); +} + +.wayf-list > li:active { + background-color: var(--color-primary); + color: var(--color-primary-text); +} + +.wayf-empty { + margin-block: 0.75rem 1rem; + color: var(--color-text-maxcontrast); +} + +.wayf-manual-form { + margin-top: 1rem; +} + +.wayf-manual-actions { + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; +} diff --git a/src/models/constants.ts b/src/models/constants.ts index fed33ce1ad..ad3ce95d72 100644 --- a/src/models/constants.ts +++ b/src/models/constants.ts @@ -4,6 +4,7 @@ */ /// +import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' import { ShareType } from '@nextcloud/sharing' @@ -29,6 +30,19 @@ export const ROUTE_CIRCLE = 'circle' export const ROUTE_CHART = 'chart' export const ROUTE_USER_GROUP = 'user_group' +const acceptInviteDialogUrl = loadState('contacts', 'acceptInviteDialogUrl', '/ocm/invite-accept-dialog') +export const ROUTE_INVITE_ACCEPT_DIALOG = acceptInviteDialogUrl +export const ROUTE_NAME_INVITE_ACCEPT_DIALOG = 'invite_accept_dialog' +export const ROUTE_ALL_OCM_INVITES = 'ocm-invites' +export const ROUTE_NAME_ALL_OCM_INVITES = 'all_ocm_invites' +export const ROUTE_NAME_OCM_INVITE = 'ocm_invite' +export const GROUP_ALL_OCM_INVITES = t('contacts', 'All invites') +export const OCM_INVITES_CONFIG_KEYS = { + optionalMail: 'ocm_invites_optional_mail', + ccSender: 'ocm_invites_cc_sender', + encodedCopyButton: 'ocm_invites_encoded_copy_button', +} as const + // Contact settings export const CONTACTS_SETTINGS: DefaultGroup = t('contacts', 'Contacts settings') diff --git a/src/models/ocminvite.ts b/src/models/ocminvite.ts new file mode 100644 index 0000000000..57f2835058 --- /dev/null +++ b/src/models/ocminvite.ts @@ -0,0 +1,59 @@ +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { t } from '@nextcloud/l10n' + +/** + * Raw invite payload as returned by the federated_invites endpoint. + */ +export interface OcmInviteData { + token: string + accepted?: boolean + recipientName?: string + recipientEmail?: string + recipientUserId?: string + recipientProvider?: string + createdAt?: number + expiredAt?: number + acceptedAt?: number +} + +/** + * Store-side shape: same data as OcmInviteData plus a derived `key` used + * for keyed lookups and stable list rendering. + */ +export interface OcmInviteEntry extends OcmInviteData { + key: string +} + +/** + * Build a store-friendly entry from a raw invite payload. Returns null + * when the payload is missing the token, since the token is the only + * field we can use as a stable key. + */ +export function toOcmInviteEntry(data: OcmInviteData | null | undefined): OcmInviteEntry | null { + if (!data || typeof data !== 'object' || !data.token) { + return null + } + return { ...data, key: data.token } +} + +/** + * Label used in lists and headings. Falls back to the recipient email, + * then to a neutral "link-only invite" string when no email was given. + */ +export function getOcmInviteDisplayName(invite: OcmInviteData): string { + return invite.recipientName || invite.recipientEmail || t('contacts', 'Link-only invite') +} + +/** + * Searchable text for an invite. Joins the recipient name and email + * when present, otherwise falls back to the same neutral label so + * link-only invites still match a search for that phrase. + */ +export function getOcmInviteSearchData(invite: OcmInviteData): string { + const parts = [invite.recipientName, invite.recipientEmail].filter(Boolean) as string[] + return parts.length > 0 ? parts.join(' ') : t('contacts', 'Link-only invite') +} diff --git a/src/router/index.js b/src/router/index.js index 4913933020..9fd19ffb97 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -6,7 +6,7 @@ import { generateUrl } from '@nextcloud/router' import { createRouter, createWebHistory } from 'vue-router' import Contacts from '../views/Contacts.vue' -import { ROUTE_CHART, ROUTE_CIRCLE, ROUTE_USER_GROUP } from '../models/constants.ts' +import { GROUP_ALL_OCM_INVITES, ROUTE_ALL_OCM_INVITES, ROUTE_CHART, ROUTE_CIRCLE, ROUTE_INVITE_ACCEPT_DIALOG, ROUTE_NAME_ALL_OCM_INVITES, ROUTE_NAME_INVITE_ACCEPT_DIALOG, ROUTE_NAME_OCM_INVITE, ROUTE_USER_GROUP } from '../models/constants.ts' // if index.php is in the url AND we got this far, then it's working: // let's keep using index.php in the url @@ -27,6 +27,23 @@ export default createRouter({ params: { selectedGroup: t('contacts', 'All contacts') }, }, children: [ + { + path: `/${ROUTE_ALL_OCM_INVITES}`, + name: ROUTE_NAME_ALL_OCM_INVITES, + component: Contacts, + meta: { selectedGroup: GROUP_ALL_OCM_INVITES }, + }, + { + path: `/${ROUTE_ALL_OCM_INVITES}/:selectedInvite`, + name: ROUTE_NAME_OCM_INVITE, + component: Contacts, + meta: { selectedGroup: GROUP_ALL_OCM_INVITES }, + }, + { + path: ROUTE_INVITE_ACCEPT_DIALOG, + name: ROUTE_NAME_INVITE_ACCEPT_DIALOG, + component: Contacts, + }, { path: `/${ROUTE_CHART}/:selectedChart`, name: 'chart', diff --git a/src/services/isOcmInvitesEnabled.js b/src/services/isOcmInvitesEnabled.js new file mode 100644 index 0000000000..555d7479bf --- /dev/null +++ b/src/services/isOcmInvitesEnabled.js @@ -0,0 +1,9 @@ +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { loadState } from '@nextcloud/initial-state' + +const isOcmInvitesEnabled = loadState('contacts', 'isOcmInvitesEnabled', false) +export default isOcmInvitesEnabled diff --git a/src/store/ocminvites.ts b/src/store/ocminvites.ts new file mode 100644 index 0000000000..481c066bbe --- /dev/null +++ b/src/store/ocminvites.ts @@ -0,0 +1,214 @@ +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { OcmInviteData, OcmInviteEntry } from '../models/ocminvite.ts' + +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' +import { defineStore } from 'pinia' +import { toOcmInviteEntry } from '../models/ocminvite.ts' +import logger from '../services/logger.js' + +interface SortedEntry { + key: string + value: string | number | boolean | undefined +} + +interface OcmInvitesState { + ocmInvites: Record + sortedOcmInvites: SortedEntry[] + orderKey: keyof OcmInviteData + inviteListStatus: 'idle' | 'loading' | 'success' | 'error' + inviteListError: string | null +} + +interface NewInvitePayload { + email?: string + message?: string + note?: string + ccSender?: boolean +} + +interface AttachEmailPayload { + token: string + email?: string + message?: string +} + +function getSortValue(value: SortedEntry['value']): string { + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + if (typeof value === 'string') { + return value.toLowerCase() + } + return '' +} + +function sortData(a: SortedEntry, b: SortedEntry): number { + const byValue = getSortValue(a.value).localeCompare(getSortValue(b.value), undefined, { numeric: true }) + if (byValue !== 0) { + return byValue + } + return a.key.localeCompare(b.key) +} + +const useOcmInvitesStore = defineStore('ocminvites', { + state: (): OcmInvitesState => ({ + // Object-keyed map for O(1) lookups; the sortedOcmInvites array + // keeps a precomputed display order so list views do not pay the + // cost of resorting on every render. + // https://codepen.io/skjnldsv/pen/ZmKvQo + ocmInvites: {}, + sortedOcmInvites: [], + orderKey: 'recipientEmail', + inviteListStatus: 'idle', + inviteListError: null, + }), + + getters: { + getOcmInvite: (state) => (key: string): OcmInviteEntry | undefined => state.ocmInvites[key], + }, + + actions: { + async fetchOcmInvites(): Promise { + this.inviteListStatus = 'loading' + this.inviteListError = null + try { + const response = await axios.get(generateUrl('/apps/contacts/ocm/invitations')) + if (!Array.isArray(response.data)) { + throw new Error('Invalid invite list payload from server') + } + const invites = response.data + this.replaceInvites(invites) + this.sortInvites() + this.inviteListStatus = 'success' + } catch (error) { + this.inviteListStatus = 'error' + this.inviteListError = error instanceof Error ? error.message : String(error) + logger.error('Error fetching OCM invites: ' + error) + throw error + } + }, + + async deleteOcmInvite(invite: OcmInviteEntry): Promise { + const token = invite.token + const url = generateUrl('/apps/contacts/ocm/invitations/{token}', { token }) + try { + await axios.delete(url) + this.removeOcmInvite(invite.key) + } catch (error) { + logger.error('Error deleting OCM invite with token ' + token) + throw error + } + }, + + async resendOcmInvite(invite: OcmInviteEntry) { + const token = invite.token + const url = generateUrl('/apps/contacts/ocm/invitations/{token}/resend', { token }) + try { + return await axios.patch(url) + } catch (error) { + logger.error('Error resending OCM invite with token ' + token) + throw error + } + }, + + async newOcmInvite(invite: NewInvitePayload) { + const url = generateUrl('/apps/contacts/ocm/invitations') + const payload = { + email: invite.email || '', + message: invite.message || '', + note: invite.note || '', + ccSender: invite.ccSender || false, + } + let response + try { + response = await axios.post(url, payload) + } catch (error) { + logger.error('Error creating a new OCM invite for ' + invite.email) + throw error + } + try { + await this.fetchOcmInvites() + } catch (error) { + logger.error('Invite created but refresh failed for ' + invite.email) + } + return response + }, + + async attachEmailAndSendOcmInvite({ token, email, message }: AttachEmailPayload) { + const url = generateUrl('/apps/contacts/ocm/invitations/{token}/email', { token }) + const payload = { + email: email || '', + message: message || '', + } + let response + try { + response = await axios.patch(url, payload) + } catch (error) { + logger.error('Error attaching email to OCM invite with token ' + token) + throw error + } + if (response?.data) { + this.updateOcmInvite(response.data) + } + return response + }, + + /** + * Stores a fresh batch of raw invite payloads from the API. Skips + * any entry without a token because we cannot key it. + */ + replaceInvites(invites: OcmInviteData[] = []): void { + this.ocmInvites = invites.reduce>((list, raw) => { + const entry = toOcmInviteEntry(raw) + if (entry) { + list[entry.key] = entry + } else { + logger.error('Invalid invite object received from API', { raw }) + } + return list + }, {}) + }, + + /** + * Recomputes the sorted index from the current invite map. + * Filtering with computed properties was too slow on large + * lists; a precomputed index is cheap to read and only refreshed + * on writes. + */ + sortInvites(): void { + const invites = Object.values(this.ocmInvites) as OcmInviteEntry[] + this.sortedOcmInvites = invites + .map((invite) => ({ key: invite.key, value: invite[this.orderKey] })) + .sort(sortData) + }, + + removeOcmInvite(key: string): void { + const index = this.sortedOcmInvites.findIndex((entry) => entry.key === key) + if (index !== -1) { + this.sortedOcmInvites.splice(index, 1) + } + delete this.ocmInvites[key] + }, + + /** + * Replaces a single cached invite with a fresh server payload, + * keyed by token. + */ + updateOcmInvite(raw: OcmInviteData): void { + const entry = toOcmInviteEntry(raw) + if (!entry) { + logger.error('Invalid invite object received from API', { raw }) + return + } + this.ocmInvites = { ...this.ocmInvites, [entry.key]: entry } + this.sortInvites() + }, + }, +}) + +export default useOcmInvitesStore diff --git a/src/views/Contacts.vue b/src/views/Contacts.vue index befd9f7dc4..e2875465a6 100644 --- a/src/views/Contacts.vue +++ b/src/views/Contacts.vue @@ -8,7 +8,7 @@
@@ -25,6 +25,30 @@ {{ isCirclesView ? t('contacts', 'Add member') : t('contacts', 'New contact') }} + + + + {{ t('contacts', 'Invite contact') }} + + + + + {{ t('contacts', 'Accept invite') }} +
@@ -38,6 +62,12 @@ + + + + + + + + + + + + + + + + + + @@ -60,53 +162,86 @@ diff --git a/src/wayf.js b/src/wayf.js new file mode 100644 index 0000000000..f076a4069a --- /dev/null +++ b/src/wayf.js @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { loadState } from '@nextcloud/initial-state' +import { translatePlural as n, translate as t } from '@nextcloud/l10n' +import { createApp } from 'vue' +import Wayf from './components/Ocm/Wayf.vue' + +import './css/wayf.scss' + +function mountWayf() { + const props = loadState('contacts', 'wayf') + const app = createApp(Wayf, props) + app.config.globalProperties.t = t + app.config.globalProperties.n = n + app.mount('#contacts-wayf') +} + +if (!document.body.id) { + document.body.id = 'body-public' +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mountWayf) +} else { + mountWayf() +} diff --git a/templates/wayf.php b/templates/wayf.php new file mode 100644 index 0000000000..12a2ef561a --- /dev/null +++ b/templates/wayf.php @@ -0,0 +1,7 @@ + +
diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ee99d2c693..37d7d28341 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -14,4 +14,5 @@ require_once __DIR__ . '/../../../tests/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php'; +Server::get(IAppManager::class)->loadApp('cloud_federation_api'); Server::get(IAppManager::class)->loadApp('contacts'); diff --git a/tests/javascript/components/ocm-accept-form.test.js b/tests/javascript/components/ocm-accept-form.test.js new file mode 100644 index 0000000000..491c3d9f9f --- /dev/null +++ b/tests/javascript/components/ocm-accept-form.test.js @@ -0,0 +1,45 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +jest.mock('@nextcloud/vue/components/NcButton', () => ({ default: {} }), { virtual: true }) +jest.mock('@nextcloud/vue/components/NcLoadingIcon', () => ({ default: {} }), { virtual: true }) +jest.mock('@nextcloud/vue/components/NcTextField', () => ({ default: {} }), { virtual: true }) +jest.mock('vue-material-design-icons/Cancel.vue', () => ({ default: {} }), { virtual: true }) +jest.mock('vue-material-design-icons/Check.vue', () => ({ default: {} }), { virtual: true }) + +import OcmAcceptForm from '../../../src/components/Ocm/OcmAcceptForm.vue' + +const component = OcmAcceptForm.default || OcmAcceptForm + +describe('OcmAcceptForm invite parser', () => { + test('parses token@provider format', () => { + const parsed = component.methods.parseInvite('token123@provider.example') + expect(parsed).toEqual({ + token: 'token123', + provider: 'provider.example', + }) + }) + + test('parses absolute invite URL format', () => { + const parsed = component.methods.parseInvite('https://cloud.example/ocm/invite-accept-dialog?token=abc123&providerDomain=provider.example') + expect(parsed).toEqual({ + token: 'abc123', + provider: 'provider.example', + }) + }) + + test('parses encoded invite format', () => { + const encoded = Buffer.from('token123@provider.example', 'utf8').toString('base64') + const parsed = component.methods.parseInvite(encoded) + expect(parsed).toEqual({ + token: 'token123', + provider: 'provider.example', + }) + }) + + test('throws on invalid invite input', () => { + expect(() => component.methods.parseInvite('not-an-invite')).toThrow('Could not parse invite') + }) +}) diff --git a/tests/javascript/store/ocminvites.test.js b/tests/javascript/store/ocminvites.test.js new file mode 100644 index 0000000000..eb97c2d45c --- /dev/null +++ b/tests/javascript/store/ocminvites.test.js @@ -0,0 +1,330 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +jest.mock('@nextcloud/axios', () => ({ + __esModule: true, + default: { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + }, +})) + +jest.mock('@nextcloud/router', () => ({ + __esModule: true, + generateUrl: (path, params = {}) => { + let result = path + for (const [key, value] of Object.entries(params)) { + result = result.replaceAll(`{${key}}`, encodeURIComponent(String(value))) + } + return result + }, +})) + +import axios from '@nextcloud/axios' +import { createPinia, setActivePinia } from 'pinia' + +import { toOcmInviteEntry } from '../../../src/models/ocminvite.ts' +import useOcmInvitesStore from '../../../src/store/ocminvites.ts' + +const TOKEN = 'token-1234' + +const flatInvitePayload = (overrides = {}) => ({ + accepted: false, + acceptedAt: null, + createdAt: 1_800_000_000, + expiredAt: 1_800_000_000 + 2_592_000, + recipientEmail: 'recipient@example.org', + recipientName: null, + recipientProvider: null, + recipientUserId: null, + token: TOKEN, + userId: 'alice', + ...overrides, +}) + +describe('ocminvites store', () => { + beforeEach(() => { + jest.clearAllMocks() + setActivePinia(createPinia()) + }) + + describe('attachEmailAndSendOcmInvite', () => { + test('PATCHes the per-invite email endpoint with the email and message payload', async () => { + axios.patch.mockResolvedValue({ data: flatInvitePayload() }) + + const store = useOcmInvitesStore() + await store.attachEmailAndSendOcmInvite({ + token: TOKEN, + email: 'recipient@example.org', + message: 'hello', + }) + + expect(axios.patch).toHaveBeenCalledTimes(1) + const [url, payload] = axios.patch.mock.calls[0] + expect(url).toBe(`/apps/contacts/ocm/invitations/${TOKEN}/email`) + expect(payload).toEqual({ + email: 'recipient@example.org', + message: 'hello', + }) + }) + + test('coerces missing email and message to empty strings', async () => { + axios.patch.mockResolvedValue({ data: flatInvitePayload() }) + + const store = useOcmInvitesStore() + await store.attachEmailAndSendOcmInvite({ token: TOKEN }) + + const [, payload] = axios.patch.mock.calls[0] + expect(payload).toEqual({ email: '', message: '' }) + }) + + test('stores a fresh invite entry from a flat backend response', async () => { + axios.patch.mockResolvedValue({ data: flatInvitePayload() }) + + const store = useOcmInvitesStore() + const response = await store.attachEmailAndSendOcmInvite({ + token: TOKEN, + email: 'recipient@example.org', + message: '', + }) + + expect(response.data.token).toBe(TOKEN) + const stored = store.ocmInvites[TOKEN] + expect(stored.key).toBe(TOKEN) + expect(stored.token).toBe(TOKEN) + expect(stored.recipientEmail).toBe('recipient@example.org') + expect(store.sortedOcmInvites).toHaveLength(1) + expect(store.sortedOcmInvites[0].key).toBe(TOKEN) + }) + + test('rethrows when the request fails and leaves state untouched', async () => { + const failure = new Error('boom') + axios.patch.mockRejectedValue(failure) + + const store = useOcmInvitesStore() + await expect( + store.attachEmailAndSendOcmInvite({ + token: TOKEN, + email: 'recipient@example.org', + message: '', + }), + ).rejects.toBe(failure) + + expect(store.ocmInvites).toEqual({}) + expect(store.sortedOcmInvites).toEqual([]) + }) + }) + + describe('fetchOcmInvites', () => { + test('replaces existing invite map with latest server payload', async () => { + axios.get.mockResolvedValue({ + data: [flatInvitePayload({ token: 'fresh-token', recipientEmail: 'fresh@example.org' })], + }) + + const store = useOcmInvitesStore() + store.ocmInvites = { + 'stale-token': toOcmInviteEntry(flatInvitePayload({ token: 'stale-token', recipientEmail: 'stale@example.org' })), + } + + await store.fetchOcmInvites() + + expect(Object.keys(store.ocmInvites)).toEqual(['fresh-token']) + expect(store.ocmInvites['fresh-token'].recipientEmail).toBe('fresh@example.org') + expect(store.inviteListStatus).toBe('success') + expect(store.inviteListError).toBeNull() + }) + + test('sorts by recipientEmail value, not token key', async () => { + axios.get.mockResolvedValue({ + data: [ + flatInvitePayload({ token: 'z-token', recipientEmail: 'zeta@example.org' }), + flatInvitePayload({ token: 'a-token', recipientEmail: 'alpha@example.org' }), + ], + }) + + const store = useOcmInvitesStore() + await store.fetchOcmInvites() + + expect(store.sortedOcmInvites.map(entry => entry.key)).toEqual(['a-token', 'z-token']) + expect(store.inviteListStatus).toBe('success') + expect(store.inviteListError).toBeNull() + }) + + test('rethrows and sets error state when request fails', async () => { + const failure = new Error('network down') + axios.get.mockRejectedValue(failure) + + const store = useOcmInvitesStore() + await expect(store.fetchOcmInvites()).rejects.toBe(failure) + + expect(store.inviteListStatus).toBe('error') + expect(store.inviteListError).toContain('network down') + }) + + test('rejects malformed payloads and sets error state', async () => { + axios.get.mockResolvedValue({ data: { invalid: true } }) + + const store = useOcmInvitesStore() + await expect(store.fetchOcmInvites()).rejects.toThrow('Invalid invite list payload from server') + + expect(store.inviteListStatus).toBe('error') + expect(store.inviteListError).toBe('Invalid invite list payload from server') + }) + }) + + describe('updateOcmInvite action', () => { + test('replaces the invite for the matching token without dropping others', () => { + const store = useOcmInvitesStore() + store.ocmInvites = { + 'other-token': toOcmInviteEntry({ token: 'other-token', recipientEmail: 'other@example.org' }), + } + + store.updateOcmInvite(flatInvitePayload({ recipientEmail: 'fresh@example.org' })) + + expect(Object.keys(store.ocmInvites)).toEqual(expect.arrayContaining(['other-token', TOKEN])) + expect(store.ocmInvites[TOKEN].recipientEmail).toBe('fresh@example.org') + expect(store.ocmInvites['other-token'].recipientEmail).toBe('other@example.org') + }) + + test('ignores payloads without a token and never mutates state', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const store = useOcmInvitesStore() + + store.updateOcmInvite({ recipientEmail: 'no-token@example.org' }) + + expect(store.ocmInvites).toEqual({}) + expect(errorSpy).toHaveBeenCalled() + errorSpy.mockRestore() + }) + }) + + describe('removeOcmInvite action', () => { + test('removes only the targeted invite from the sorted list', () => { + const a = toOcmInviteEntry({ token: 'a' }) + const b = toOcmInviteEntry({ token: 'b' }) + const store = useOcmInvitesStore() + store.ocmInvites = { a, b } + store.sortedOcmInvites = [a, b] + + store.removeOcmInvite('a') + + expect(store.sortedOcmInvites.map(i => i.key)).toEqual(['b']) + expect(store.ocmInvites).not.toHaveProperty('a') + expect(store.ocmInvites).toHaveProperty('b') + }) + + test('does not splice the last entry when the key is unknown', () => { + const a = toOcmInviteEntry({ token: 'a' }) + const b = toOcmInviteEntry({ token: 'b' }) + const store = useOcmInvitesStore() + store.ocmInvites = { a, b } + store.sortedOcmInvites = [a, b] + + store.removeOcmInvite('missing-key') + + expect(store.sortedOcmInvites.map(i => i.key)).toEqual(['a', 'b']) + expect(store.ocmInvites).toEqual({ a, b }) + }) + }) + + describe('deleteOcmInvite', () => { + test('throws when revoke request fails', async () => { + const failure = new Error('delete failed') + axios.delete.mockRejectedValue(failure) + + const store = useOcmInvitesStore() + const invite = toOcmInviteEntry(flatInvitePayload()) + await expect(store.deleteOcmInvite(invite)).rejects.toBe(failure) + }) + }) + + describe('create/resend behavior', () => { + test('newOcmInvite refreshes invite list after create', async () => { + axios.post.mockResolvedValue({ data: { invite: '/invite/link' } }) + axios.get.mockResolvedValue({ data: [flatInvitePayload({ token: 'new-token' })] }) + + const store = useOcmInvitesStore() + await store.newOcmInvite({ + email: 'recipient@example.org', + message: 'See you soon', + note: 'CERN contact', + ccSender: true, + }) + + expect(axios.post).toHaveBeenCalledTimes(1) + expect(axios.post).toHaveBeenCalledWith('/apps/contacts/ocm/invitations', { + email: 'recipient@example.org', + message: 'See you soon', + note: 'CERN contact', + ccSender: true, + }) + expect(axios.get).toHaveBeenCalledTimes(1) + expect(store.ocmInvites['new-token']).toBeDefined() + }) + + test('newOcmInvite defaults optional create payload fields', async () => { + axios.post.mockResolvedValue({ data: { invite: '/invite/link' } }) + axios.get.mockResolvedValue({ data: [] }) + + const store = useOcmInvitesStore() + await store.newOcmInvite({}) + + expect(axios.post).toHaveBeenCalledWith('/apps/contacts/ocm/invitations', { + email: '', + message: '', + note: '', + ccSender: false, + }) + }) + + test('resendOcmInvite returns resend response without reloading invites', async () => { + const resendResponse = { data: { invite: '/invite/link' } } + axios.patch.mockResolvedValue(resendResponse) + + const store = useOcmInvitesStore() + const invite = toOcmInviteEntry(flatInvitePayload()) + await expect(store.resendOcmInvite(invite)).resolves.toBe(resendResponse) + + expect(axios.patch).toHaveBeenCalledTimes(1) + expect(axios.get).not.toHaveBeenCalled() + }) + + test('newOcmInvite resolves when refresh fetch fails', async () => { + const failure = new Error('refresh failed') + const createResponse = { data: { invite: '/invite/link' } } + axios.post.mockResolvedValue(createResponse) + axios.get.mockRejectedValue(failure) + + const store = useOcmInvitesStore() + await expect(store.newOcmInvite({ email: 'recipient@example.org', message: '', note: '' })).resolves.toBe(createResponse) + + expect(store.inviteListStatus).toBe('error') + expect(store.inviteListError).toContain('refresh failed') + }) + + test('newOcmInvite rejects when create request fails', async () => { + const failure = new Error('create failed') + axios.post.mockRejectedValue(failure) + + const store = useOcmInvitesStore() + await expect(store.newOcmInvite({ email: 'recipient@example.org', message: '', note: '' })).rejects.toBe(failure) + + expect(axios.get).not.toHaveBeenCalled() + }) + + test('resendOcmInvite rejects when resend request fails', async () => { + const failure = new Error('resend failed') + axios.patch.mockRejectedValue(failure) + + const store = useOcmInvitesStore() + const invite = toOcmInviteEntry(flatInvitePayload()) + await expect(store.resendOcmInvite(invite)).rejects.toBe(failure) + + expect(axios.get).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests/javascript/views/contacts-ocm-flows.test.js b/tests/javascript/views/contacts-ocm-flows.test.js new file mode 100644 index 0000000000..34b89fe1f9 --- /dev/null +++ b/tests/javascript/views/contacts-ocm-flows.test.js @@ -0,0 +1,202 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +jest.mock('@nextcloud/auth', () => ({ + getCurrentUser: jest.fn(() => ({ uid: 'alice' })), +})) + +jest.mock('@nextcloud/axios', () => ({ + __esModule: true, + default: { + patch: jest.fn(), + }, +})) + +jest.mock('@nextcloud/dialogs', () => ({ + showError: jest.fn(), +})) + +jest.mock('@nextcloud/event-bus', () => ({ + emit: jest.fn(), +})) + +jest.mock('@nextcloud/initial-state', () => ({ + loadState: jest.fn((app, key, fallback) => fallback), +})) + +jest.mock('@nextcloud/router', () => ({ + generateUrl: (path, params = {}) => { + let result = path + for (const [key, value] of Object.entries(params)) { + result = result.replace(`{${key}}`, String(value)) + } + return result + }, +})) + +jest.mock('@nextcloud/vue', () => ({ + NcButton: {}, + NcContent: {}, + NcLoadingIcon: {}, + NcModal: {}, +})) + +jest.mock('ical.js', () => ({})) + +jest.mock('pinia', () => ({ + mapStores: () => ({}), +})) + +jest.mock('../../../src/components/AppContent/ChartContent.vue', () => ({ default: {} })) +jest.mock('../../../src/components/AppContent/CircleContent.vue', () => ({ default: {} })) +jest.mock('../../../src/components/AppContent/ContactsContent.vue', () => ({ default: {} })) +jest.mock('../../../src/components/AppContent/OcmInvitesContent.vue', () => ({ default: {} })) +jest.mock('../../../src/components/AppNavigation/RootNavigation.vue', () => ({ default: {} })) +jest.mock('../../../src/components/AppNavigation/Settings/SettingsImportContacts.vue', () => ({ default: {} })) +jest.mock('../../../src/components/EntityPicker/ContactsPicker.vue', () => ({ default: {} })) +jest.mock('../../../src/components/Ocm/OcmAcceptForm.vue', () => ({ default: {} })) +jest.mock('../../../src/components/Ocm/OcmInviteAccept.vue', () => ({ default: {} })) +jest.mock('../../../src/components/Ocm/OcmInviteForm.vue', () => ({ default: {} })) +jest.mock('../../../src/views/Processing/ImportView.vue', () => ({ default: {} })) +jest.mock('../../../src/mixins/IsMobileMixin.ts', () => ({ default: {} })) +jest.mock('../../../src/mixins/RouterMixin.js', () => ({ default: {} })) +jest.mock('../../../src/models/constants.ts', () => ({ + GROUP_ALL_CONTACTS: 'all', + GROUP_ALL_OCM_INVITES: 'all-ocm', + GROUP_NO_GROUP_CONTACTS: 'nogroup', + ROUTE_CIRCLE: 'circle', + ROUTE_NAME_ALL_OCM_INVITES: 'all_ocm_invites', + ROUTE_NAME_INVITE_ACCEPT_DIALOG: 'invite_accept_dialog', + ROUTE_NAME_OCM_INVITE: 'ocm_invite', + ROUTE_USER_GROUP: 'user-group', +})) +jest.mock('../../../src/models/contact.js', () => ({ default: class Contact {} })) +jest.mock('../../../src/models/rfcProps.js', () => ({ default: {} })) +jest.mock('../../../src/services/cdav.js', () => ({ default: {} })) +jest.mock('../../../src/services/isCirclesEnabled.js', () => ({ default: false })) +jest.mock('../../../src/services/isOcmInvitesEnabled.js', () => ({ default: true })) +jest.mock('../../../src/services/logger.js', () => ({ + __esModule: true, + default: { + error: jest.fn(), + }, +})) +jest.mock('../../../src/store/ocminvites.ts', () => ({ default: jest.fn() })) +jest.mock('../../../src/store/principals.js', () => ({ default: jest.fn() })) +jest.mock('../../../src/store/userGroup.ts', () => ({ default: jest.fn() })) +jest.mock('vue-material-design-icons/AccountArrowDownOutline.vue', () => ({ default: {} })) +jest.mock('vue-material-design-icons/AccountSwitchOutline.vue', () => ({ default: {} })) +jest.mock('vue-material-design-icons/Cancel.vue', () => ({ default: {} })) +jest.mock('vue-material-design-icons/Check.vue', () => ({ default: {} })) +jest.mock('vue-material-design-icons/Plus.vue', () => ({ default: {} })) + +import axios from '@nextcloud/axios' +import { showError } from '@nextcloud/dialogs' +import Contacts from '../../../src/views/Contacts.vue' + +const view = Contacts.default || Contacts + +describe('Contacts OCM flow methods', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('sendNewInvite keeps draft modal open on create failure', async () => { + const createFailure = { response: { data: { message: 'Could not create invite' } } } + const vm = { + loadingUpdate: false, + showNewInviteForm: true, + ocmInvite: { email: 'recipient@example.org', message: '', note: '', sendEmail: false }, + ocmInvitesConfig: { optionalMail: true }, + ocminvitesStore: { + newOcmInvite: jest.fn().mockRejectedValue(createFailure), + }, + cancelNewInvite: jest.fn(), + t: (app, text) => text, + } + + await view.methods.sendNewInvite.call(vm) + + expect(vm.cancelNewInvite).not.toHaveBeenCalled() + expect(vm.showNewInviteForm).toBe(true) + expect(showError).toHaveBeenCalledWith('Could not create invite') + expect(vm.loadingUpdate).toBe(false) + }) + + test('sendNewInvite reports the short missing email message', async () => { + const vm = { + loadingUpdate: false, + ocmInvite: { email: '', message: '', note: '', sendEmail: true }, + ocmInvitesConfig: { optionalMail: true }, + ocminvitesStore: { + newOcmInvite: jest.fn(), + }, + t: (app, text) => text, + } + + await view.methods.sendNewInvite.call(vm) + + expect(showError).toHaveBeenCalledWith('Please enter an email address.') + expect(vm.ocminvitesStore.newOcmInvite).not.toHaveBeenCalled() + expect(vm.loadingUpdate).toBe(false) + }) + + test('sendNewInvite submits link-only invites with an empty email', async () => { + const createFailure = { response: { data: { message: 'backend reached' } } } + const vm = { + loadingUpdate: false, + ocmInvite: { email: '', message: 'hello', note: 'mesh peer', sendEmail: false }, + showNewInviteForm: true, + ocminvitesStore: { + newOcmInvite: jest.fn().mockRejectedValue(createFailure), + }, + cancelNewInvite: jest.fn(), + t: (app, text) => text, + } + + await view.methods.sendNewInvite.call(vm) + + expect(vm.ocminvitesStore.newOcmInvite).toHaveBeenCalledWith(vm.ocmInvite) + expect(vm.cancelNewInvite).not.toHaveBeenCalled() + expect(showError).toHaveBeenCalledWith('backend reached') + expect(showError).not.toHaveBeenCalledWith('Please enter an email address.') + expect(vm.loadingUpdate).toBe(false) + }) + + test('handleAccept keeps manual modal open on failure', async () => { + axios.patch.mockRejectedValueOnce({ response: { data: { message: 'manual accept failed' } } }) + const vm = { + loadingUpdate: false, + showManualInvite: true, + t: (app, text) => text, + } + + await view.methods.handleAccept.call(vm, { + provider: 'provider.example', + token: 'invite-token', + }) + + expect(vm.showManualInvite).toBe(true) + expect(showError).toHaveBeenCalledWith('manual accept failed') + expect(vm.loadingUpdate).toBe(false) + }) + + test('acceptInvite keeps deep-link dialog open on failure', async () => { + axios.patch.mockRejectedValueOnce({ response: { data: { message: 'deep-link failed' } } }) + const vm = { + loadingUpdate: false, + showInviteAcceptDialog: true, + inviteToken: 'invite-token', + inviteProvider: 'provider.example', + t: (app, text) => text, + } + + await view.methods.acceptInvite.call(vm) + + expect(vm.showInviteAcceptDialog).toBe(true) + expect(showError).toHaveBeenCalledWith('deep-link failed') + expect(vm.loadingUpdate).toBe(false) + }) +}) diff --git a/tests/unit/Controller/FederatedInvitesControllerTest.php b/tests/unit/Controller/FederatedInvitesControllerTest.php new file mode 100644 index 0000000000..10a154e09c --- /dev/null +++ b/tests/unit/Controller/FederatedInvitesControllerTest.php @@ -0,0 +1,831 @@ +request = $this->createMock(IRequest::class); + $this->addressHandler = $this->createMock(AddressHandler::class); + $this->defaults = $this->createMock(Defaults::class); + $this->mapper = $this->createMock(FederatedInviteMapper::class); + $this->invitesService = $this->createMock(FederatedInvitesService::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->httpClient = $this->createMock(IClientService::class); + $this->config = $this->createMock(IConfig::class); + $this->initialState = $this->createMock(IInitialState::class); + $this->languageFactory = $this->createMock(IFactory::class); + $this->contactsManager = $this->createMock(IManager::class); + $this->mailer = $this->createMock(IMailer::class); + $this->discovery = $this->createMock(TestIOCMDiscoveryService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->wayfProvider = $this->createMock(WayfProvider::class); + $this->socialApi = $this->createMock(SocialApiService::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->compareVersion = $this->createMock(CompareVersion::class); + $this->groupSharingService = $this->createMock(GroupSharingService::class); + $this->l10n = $this->createMock(IL10N::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->l10n->method('t')->willReturnCallback(static fn (string $text, array $params = []): string => vsprintf($text, $params)); + $this->invitesService->method('isOcmInvitesEnabled')->willReturn(true); + $this->mapper->method('deleteSupersededInvitesForRecipientEmail')->willReturn(0); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn(self::UID); + $user->method('getDisplayName')->willReturn('Alice'); + $user->method('getEMailAddress')->willReturn('alice@example.org'); + $this->userSession->method('getUser')->willReturn($user); + + $this->controller = new FederatedInvitesController( + $this->request, + $this->addressHandler, + $this->defaults, + $this->mapper, + $this->invitesService, + $this->appManager, + $this->httpClient, + $this->config, + $this->initialState, + $this->languageFactory, + $this->contactsManager, + $this->mailer, + $this->discovery, + $this->userSession, + $this->wayfProvider, + $this->socialApi, + $this->timeFactory, + $this->compareVersion, + $this->groupSharingService, + $this->l10n, + $this->urlGenerator, + $this->userManager, + $this->logger, + ); + } + + private function makeInvite(?string $email = null, bool $accepted = false, string $uid = self::UID): FederatedInvite { + $invite = new FederatedInvite(); + $invite->setUserId($uid); + $invite->setToken(self::TOKEN); + $invite->setRecipientEmail($email); + $invite->setAccepted($accepted); + $invite->setCreatedAt(1_700_000_000); + $invite->setExpiredAt(1_700_000_000 + 2_592_000); + return $invite; + } + + public function testGetInvitesReturnsStructuredErrorWhenMapperFails(): void { + $this->mapper->expects($this->once()) + ->method('findOpenInvitesByUid') + ->with(self::UID) + ->willThrowException(new \RuntimeException('db fail')); + + $response = $this->controller->getInvites(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertSame('ocm_invites_fetch_failed', $response->getData()['code']); + } + + public function testAttachEmailAndSendUpdatesAndSends(): void { + $invite = $this->makeInvite(null); + + $this->mapper->expects($this->once()) + ->method('findInviteByTokenAndUid') + ->with(self::TOKEN, self::UID) + ->willReturn($invite); + $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]); + $this->mailer->method('validateMailAddress')->willReturn(true); + $this->mailer->method('createMessage')->willReturn($this->createMock(\OCP\Mail\IMessage::class)); + $this->mailer->method('send')->willReturn([]); + $this->wayfProvider->method('getWayfEndpoint')->willReturn('https://example.org/wayf'); + $this->invitesService->method('getProviderFQDN')->willReturn('example.org'); + $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000); + $now = $this->createMock(\DateTimeImmutable::class); + $now->method('getTimestamp')->willReturn(1_800_000_000); + $this->timeFactory->method('now')->willReturn($now); + + $this->mapper->expects($this->once()) + ->method('claimInviteForEmail') + ->with(self::TOKEN, self::UID, 'recipient@example.org', 1_800_000_000, 1_800_000_000 + 2_592_000) + ->willReturn(true); + $this->mapper->expects($this->never())->method('revertInviteEmail'); + + $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org', 'hello'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $body = $response->getData(); + $this->assertSame('recipient@example.org', $body['recipientEmail']); + $this->assertSame(self::TOKEN, $body['token']); + $this->assertSame(1_800_000_000, $body['createdAt']); + } + + public function testAttachEmailAndSendRejectsWhenClaimLosesRace(): void { + $invite = $this->makeInvite(null); + + $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite); + $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]); + $this->mailer->method('validateMailAddress')->willReturn(true); + $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000); + $now = $this->createMock(\DateTimeImmutable::class); + $now->method('getTimestamp')->willReturn(1_800_000_000); + $this->timeFactory->method('now')->willReturn($now); + + $this->mapper->expects($this->once()) + ->method('claimInviteForEmail') + ->willReturn(false); + $this->mailer->expects($this->never())->method('send'); + $this->mapper->expects($this->never())->method('revertInviteEmail'); + + $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org'); + + $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertSame('ocm_invite_claim_failed', $response->getData()['code']); + $this->assertNull($invite->getRecipientEmail()); + } + + public function testAttachEmailAndSendReturnsClaimExceptionCodeWhenClaimFails(): void { + $invite = $this->makeInvite(null); + + $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite); + $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]); + $this->mailer->method('validateMailAddress')->willReturn(true); + $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000); + $now = $this->createMock(\DateTimeImmutable::class); + $now->method('getTimestamp')->willReturn(1_800_000_000); + $this->timeFactory->method('now')->willReturn($now); + + $this->mapper->expects($this->once()) + ->method('claimInviteForEmail') + ->willThrowException(new \RuntimeException('claim boom')); + $this->mailer->expects($this->never())->method('send'); + $this->mapper->expects($this->never())->method('revertInviteEmail'); + + $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org'); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertSame('ocm_invite_claim_exception', $response->getData()['code']); + } + + public function testAttachEmailAndSendRejectsWhenInviteBelongsToAnotherUser(): void { + $this->mapper->expects($this->once()) + ->method('findInviteByTokenAndUid') + ->with(self::TOKEN, self::UID) + ->willThrowException(new DoesNotExistException('not found')); + + $this->mailer->expects($this->never())->method('send'); + $this->mapper->expects($this->never())->method('claimInviteForEmail'); + $this->mapper->expects($this->never())->method('revertInviteEmail'); + + $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org'); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + } + + public function testAttachEmailAndSendRejectsWhenInviteAlreadyAccepted(): void { + $invite = $this->makeInvite(null, accepted: true); + $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite); + + $this->mailer->expects($this->never())->method('send'); + $this->mapper->expects($this->never())->method('claimInviteForEmail'); + + $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org'); + + $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertSame('ocm_invite_already_accepted', $response->getData()['code']); + } + + public function testAttachEmailAndSendRejectsWhenInviteAlreadyHasEmail(): void { + $invite = $this->makeInvite('existing@example.org'); + $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite); + + $this->mailer->expects($this->never())->method('send'); + $this->mapper->expects($this->never())->method('claimInviteForEmail'); + + $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org'); + + $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertSame('ocm_invite_already_has_email', $response->getData()['code']); + } + + public function testAttachEmailAndSendRejectsInvalidEmail(): void { + $invite = $this->makeInvite(null); + $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite); + $this->mailer->method('validateMailAddress')->willReturn(false); + + $this->mailer->expects($this->never())->method('send'); + $this->mapper->expects($this->never())->method('claimInviteForEmail'); + + $response = $this->controller->attachEmailAndSend(self::TOKEN, 'not-an-email'); + + $this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $response->getStatus()); + $this->assertNull($invite->getRecipientEmail()); + } + + public function testAttachEmailAndSendRejectsCollidingOpenInvite(): void { + $invite = $this->makeInvite(null); + $other = $this->makeInvite('recipient@example.org'); + $other->setToken('other-token'); + + $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite); + $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([$other]); + $this->mailer->method('validateMailAddress')->willReturn(true); + + $this->mailer->expects($this->never())->method('send'); + $this->mapper->expects($this->never())->method('claimInviteForEmail'); + + $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org'); + + $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus()); + $this->assertSame('ocm_invite_duplicate_recipient_email', $response->getData()['code']); + } + + public function testAttachEmailAndSendRevertsOnMailerFailure(): void { + $invite = $this->makeInvite(null); + $originalCreatedAt = $invite->getCreatedAt(); + $originalExpiredAt = $invite->getExpiredAt(); + + $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite); + $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]); + $this->mailer->method('validateMailAddress')->willReturn(true); + $this->mailer->method('createMessage')->willReturn($this->createMock(\OCP\Mail\IMessage::class)); + $this->mailer->method('send')->willReturn(['recipient@example.org']); + $this->wayfProvider->method('getWayfEndpoint')->willReturn('https://example.org/wayf'); + $this->invitesService->method('getProviderFQDN')->willReturn('example.org'); + $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000); + $now = $this->createMock(\DateTimeImmutable::class); + $now->method('getTimestamp')->willReturn(1_800_000_000); + $this->timeFactory->method('now')->willReturn($now); + + $this->mapper->expects($this->once()) + ->method('claimInviteForEmail') + ->willReturn(true); + $this->mapper->expects($this->once()) + ->method('revertInviteEmail') + ->with(self::TOKEN, self::UID, 'recipient@example.org', $originalCreatedAt, $originalExpiredAt) + ->willReturn(true); + + $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org'); + + $this->assertNotSame(Http::STATUS_OK, $response->getStatus()); + } + + public function testAttachEmailAndSendReturnsRevertFailureWhenRevertMisses(): void { + $invite = $this->makeInvite(null); + $originalCreatedAt = $invite->getCreatedAt(); + $originalExpiredAt = $invite->getExpiredAt(); + + $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite); + $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]); + $this->mailer->method('validateMailAddress')->willReturn(true); + $this->mailer->method('createMessage')->willReturn($this->createMock(\OCP\Mail\IMessage::class)); + $this->mailer->method('send')->willReturn(['recipient@example.org']); + $this->wayfProvider->method('getWayfEndpoint')->willReturn('https://example.org/wayf'); + $this->invitesService->method('getProviderFQDN')->willReturn('example.org'); + $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000); + $now = $this->createMock(\DateTimeImmutable::class); + $now->method('getTimestamp')->willReturn(1_800_000_000); + $this->timeFactory->method('now')->willReturn($now); + + $this->mapper->expects($this->once()) + ->method('claimInviteForEmail') + ->willReturn(true); + $this->mapper->expects($this->once()) + ->method('revertInviteEmail') + ->with(self::TOKEN, self::UID, 'recipient@example.org', $originalCreatedAt, $originalExpiredAt) + ->willReturn(false); + + $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org'); + + $this->assertSame(Http::STATUS_BAD_GATEWAY, $response->getStatus()); + $body = $response->getData(); + $this->assertSame('ocm_invite_revert_failed', $body['code']); + $this->assertNotEmpty($body['mailError']); + } + + public function testAttachEmailAndSendEscapesUserContentInHtmlBody(): void { + $invite = $this->makeInvite(null); + $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite); + $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]); + $this->mailer->method('validateMailAddress')->willReturn(true); + $this->wayfProvider->method('getWayfEndpoint')->willReturn('https://example.org/wayf'); + $this->invitesService->method('getProviderFQDN')->willReturn('example.org'); + $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000); + $now = $this->createMock(\DateTimeImmutable::class); + $now->method('getTimestamp')->willReturn(1_800_000_000); + $this->timeFactory->method('now')->willReturn($now); + $this->mapper->method('claimInviteForEmail')->willReturn(true); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn(self::UID); + $user->method('getDisplayName')->willReturn('Eve