diff --git a/appinfo/info.xml b/appinfo/info.xml index 351cce1878..cb2c281c4d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -49,6 +49,8 @@ OCA\Contacts\Cron\SocialUpdateRegistration + OCA\Contacts\Cron\UpdateOcmProviders + OCA\Contacts\Cron\UpdateDeltaOcmProviders 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/ConfigLexicon.php b/lib/ConfigLexicon.php new file mode 100644 index 0000000000..27031c6540 --- /dev/null +++ b/lib/ConfigLexicon.php @@ -0,0 +1,117 @@ +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; + } + + $uid = $this->userSession->getUser()->getUID(); + try { + $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 + * by redirecting to the index page with the required invite accept dialog parameters. + * + * @param string $token + * @param string $providerDomain + * @return RedirectResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE)] + public function inviteAcceptDialog(string $token = '', string $providerDomain = ''): RedirectResponse { + return new RedirectResponse($this->urlGenerator->linkToRoute('contacts.page.index', [ + 'token' => $token, + 'providerDomain' => $providerDomain, + ])); + } + + /** + * 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 + * @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 = ''): 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); + } + $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->sendInvitationEmail($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; + } + } + + // 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); + } + $recipientProviderFqdn = $this->federatedInvitesService->getProviderFQDN(); + // do not accept an invite from this instance + $senderProviderFqdn = parse_url($provider)['host']; + $this->logger->debug("acceptInvite() - $recipientProviderFqdn === $senderProviderFqdn"); + if ($recipientProviderFqdn === $senderProviderFqdn) { + return new JSONResponse(['message' => $this->il10->t('Unable to accept this invitation. You are trying to accept an outgoing invitation.')], Http::STATUS_BAD_REQUEST); + } + $userId = $localUser->getUID(); + $email = $localUser->getEMailAddress(); + $name = $localUser->getDisplayName(); + if ($recipientProviderFqdn === '' || $userId === '' || $email === '' || $name === '') { + $this->logger->error("All of these must be set: recipientProvider: $recipientProviderFqdn, 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); + if ( + $discovered->hasCapability('invites') + || $discovered->hasCapability('invite-accepted') + || $discovered->hasCapability('/invite-accepted') + ) { + + $response = $this->discovery->requestRemoteOcmEndpoint( + null, + $provider, + '/invite-accepted', + [ + 'recipientProvider' => $recipientProviderFqdn, + '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 (ContactAlreadyExistsException $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->sendInvitationEmail($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->sendInvitationEmail($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($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); + } + } + + /** + * Display the wayf page. + * + * @param string $token the token 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->getMeshProviders(); + $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; + } + + /** + * 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 $recipientEmail the recipient email address to send the invitation to + * @param string $message the optional message to send with the invitation + * @param bool $isSenderCopy if true than a this is a copy of the invitation to be sent to the inviter + * @return JSONResponse + */ + private function sendInvitationEmail(string $token, string $senderProvider, string $recipientEmail, string $message, bool $isSenderCopy = false): JSONResponse { + $validationError = $this->validateEmail($recipientEmail); + if ($validationError !== null) { + return $validationError; + } + /** @var IMessage */ + $email = $this->mailer->createMessage(); + $toAddress = $isSenderCopy ? $this->userSession->getUser()->getEMailAddress() : $recipientEmail; + $email->setTo([$toAddress]); + + $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]); + $subjectPrefix = $isSenderCopy ? '[Copy] ' : ''; + $subject = $this->il10->t($subjectPrefix . '%1$s invites you to exchange contact information.', [$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 = $isSenderCopy + ? $this->il10->t('This is a copy of the invitation you\'ve sent to %1$s', [htmlspecialchars($recipientEmail, ENT_QUOTES, 'UTF-8')]) + : ''; + $greeting = $this->il10->t('Hi there,', []); + $explanationLine1 = $this->il10->t('%1$s invites you to exchange cloud accounts and contact information.', [$initiatorDisplayNameH]); + $explanationLine2 = $this->il10->t('This will allow you to share data with each other.', []); + $htmlHeader = $header === '' ? $header : "$header


"; + $htmlInvitation = "$htmlHeader$greeting

$explanationLine1
$explanationLine2"; + $htmlPersonalMessage = trim($message) === '' ? '' : "
---
$messageH
---
"; + + $inviteLinkNote = $this->il10->t('To accept this invite, click the link below and sign in with your cloud provider:', []); + $htmlInviteLink = "$inviteLinkH"; + + $technicalDetailsNote = $this->il10->t('Invitation details:', []); + $technicalDetailsInviteCode = $this->il10->t('Invite code: %1$s', [$tokenSenderH]); + $technicalDetailsEncodedInvite = $this->il10->t('Encoded invite: %1$s', [$encodedH]); + $htmlTechnicalDetails = "$technicalDetailsNote
$technicalDetailsInviteCode
$technicalDetailsEncodedInvite
"; + $htmlBody = "$htmlInvitation
$htmlPersonalMessage
$inviteLinkNote
$htmlInviteLink

$htmlTechnicalDetails"; + $email->setHtmlBody($htmlBody); + + $plainHeader = $header === '' ? $header : "$header\n---------\n\n"; + $plainInvitation = "$plainHeader$greeting\n\n$explanationLine1\n$explanationLine2"; + $plainPersonalMessage = trim($message) === '' ? '' : "\n---\n$message\n---\n"; + $plainInviteLinkNote = $this->il10->t('To accept this invite, use the url below to sign in with your cloud provider:', []); + $plainTechnicalDetails = "$technicalDetailsNote\n$technicalDetailsInviteCode\n$technicalDetailsEncodedInvite"; + + $plainBody = "$plainInvitation\n$plainPersonalMessage\n$plainInviteLinkNote\n$inviteLink\n\n$plainTechnicalDetails\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 '$toAddress': " . $e->getMessage(), [ + 'app' => Application::APP_ID, + 'exception' => $e, + ]); + return new JSONResponse(['message' => "Could not send invite to '$toAddress'"], Http::STATUS_BAD_GATEWAY); + } + + if (!empty($failedRecipients)) { + $this->logger->error("Could not send invite to '$toAddress'", ['app' => Application::APP_ID]); + return new JSONResponse(['message' => "Could not send invite to '$toAddress'"], 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($parts['scheme']); + if (!in_array($scheme, ['http', 'https'], true)) { + return null; + } + + $host = strtolower($parts['host']); + if ($host === '') { + return null; + } + if (!$this->federatedInvitesService->isSsrfGuardDisabled() && $this->isBlockedDiscoveryHost($host)) { + return null; + } + + $port = ''; + if (isset($parts['port'])) { + $portNumber = $parts['port']; + if ($portNumber < 1 || $portNumber > 65535) { + return null; + } + $port = ':' . $portNumber; + } + + $path = ''; + if (isset($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($dialogParts['host']) !== strtolower($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 0dbdfce034..d516f96d84 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, @@ -41,9 +43,18 @@ public function __construct( * @NoAdminRequired * @NoCSRFRequired * - * Default routing + * Default routing. + * + * @param string $token external invitation token + * @param string $providerDomain external invitation provider domain */ - public function index(): TemplateResponse { + public function index(string $token = '', string $providerDomain = ''): TemplateResponse { + if ($token !== '' && $providerDomain !== '') { + // if both token and providerDomain are set they will be provided to the template system for displaying the invite accept dialog + $this->initialState->provideInitialState('inviteToken', $token); + $this->initialState->provideInitialState('inviteProvider', $providerDomain); + $this->initialState->provideInitialState('acceptInviteDialogUrl', FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE); + } $user = $this->userSession->getUser(); $userId = $user->getUid(); @@ -65,6 +76,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); @@ -75,6 +88,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/UpdateDeltaOcmProviders.php b/lib/Cron/UpdateDeltaOcmProviders.php new file mode 100644 index 0000000000..ee7fd509e6 --- /dev/null +++ b/lib/Cron/UpdateDeltaOcmProviders.php @@ -0,0 +1,43 @@ +setInterval($this->expire_time); + } + + #[\Override] + protected function run($argument) { + $this->wayfProvider->updateMeshProvidersCache(ConfigLexicon::FEDERATIONS_CACHE_DELTA_EXPIRES); + $this->cache->setExpirationTime(ConfigLexicon::FEDERATIONS_CACHE_DELTA_EXPIRES, time() + $this->expire_time); + } +} diff --git a/lib/Cron/UpdateOcmProviders.php b/lib/Cron/UpdateOcmProviders.php new file mode 100644 index 0000000000..9be553222e --- /dev/null +++ b/lib/Cron/UpdateOcmProviders.php @@ -0,0 +1,45 @@ +setInterval($this->expire_time); + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + } + + #[\Override] + protected function run($argument) { + $this->wayfProvider->updateMeshProvidersCache(ConfigLexicon::FEDERATIONS_CACHE_EXPIRES); + $this->cache->setExpirationTime(ConfigLexicon::FEDERATIONS_CACHE_EXPIRES, time() + $this->expire_time); + } +} diff --git a/lib/Db/FederatedInvite.php b/lib/Db/FederatedInvite.php new file mode 100644 index 0000000000..1ea4cc3db6 --- /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..ae276819cd --- /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/ContactAlreadyExistsException.php b/lib/Exception/ContactAlreadyExistsException.php new file mode 100644 index 0000000000..630ea946f0 --- /dev/null +++ b/lib/Exception/ContactAlreadyExistsException.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..e339cfe311 --- /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/MeshProvidersCache.php b/lib/MeshProvidersCache.php new file mode 100644 index 0000000000..bb9cce35cc --- /dev/null +++ b/lib/MeshProvidersCache.php @@ -0,0 +1,103 @@ + [[...provider a...], [...]], + * 'mesh 2' => [[...]], + * 'expires' => 1781280096, + * 'delta_expires' => 1781280095 + * ] + * + * Two kinds of expirations are used: + * - FEDERATIONS_CACHE_EXPIRES - should result in a full cache rebuild + * - FEDERATIONS_CACHE_DELTA_EXPIRES - should update the cache for new or removed providers + */ +class MeshProvidersCache { + + private array $expirationTimes; + + public function __construct( + private IAppConfig $appConfig, + ) { + $data = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, [], true); + $this->expirationTimes = [ + ConfigLexicon::FEDERATIONS_CACHE_EXPIRES => $data[ConfigLexicon::FEDERATIONS_CACHE_EXPIRES] ?? 0, + ConfigLexicon::FEDERATIONS_CACHE_DELTA_EXPIRES => $data[ConfigLexicon::FEDERATIONS_CACHE_DELTA_EXPIRES] ?? 0 + ]; + } + + /** + * Returns all federations as associative array: + * [ + * 'mesh_A' => [[...provider_a...], [...]], + * 'mesh_B' => [[...]], + * ] + * + * @return array + */ + public function getFederations(): array { + $data = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, [], true); + unset($data[ConfigLexicon::FEDERATIONS_CACHE_EXPIRES]); + unset($data[ConfigLexicon::FEDERATIONS_CACHE_DELTA_EXPIRES]); + return $data; + } + + /** + * Replaces the federations in the cache with the specified ones. + * + * @param array $federations + * @return void + */ + public function setFederations(array $federations): void { + $cachedData = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, [], true); + $federations[ConfigLexicon::FEDERATIONS_CACHE_EXPIRES] = $cachedData[ConfigLexicon::FEDERATIONS_CACHE_EXPIRES] ?? 0; + $federations[ConfigLexicon::FEDERATIONS_CACHE_DELTA_EXPIRES] = $cachedData[ConfigLexicon::FEDERATIONS_CACHE_DELTA_EXPIRES] ?? 0; + $this->appConfig->setValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, $federations, true); + } + + /** + * Sets the expiration time of the specified kind to the specified time. + * + * @param string $expirationTimeKey the kind of expiration to set + * @param int $time + * @return void + */ + public function setExpirationTime(string $expirationTimeKey, int $time): void { + $this->expirationTimes[$expirationTimeKey] = $time; + $data = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, [], true); + $data[$expirationTimeKey] = $time; + $this->appConfig->setValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, $data, true); + } + + /** + * Check the expiration time. + * + * @param $expirationTimeKey the key for which to check the expiration time + * @return bool + */ + public function hasExpired(string $expirationTimeKey): bool { + $cachedData = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, [], true); + $deltaExpirationTime = $cachedData[ConfigLexicon::FEDERATIONS_CACHE_DELTA_EXPIRES] ?? 0; + if ($expirationTimeKey === ConfigLexicon::FEDERATIONS_CACHE_DELTA_EXPIRES && $deltaExpirationTime < time()) { + return true; + } + $expirationTime = $cachedData[ConfigLexicon::FEDERATIONS_CACHE_EXPIRES] ?? 0; + if ($expirationTimeKey === ConfigLexicon::FEDERATIONS_CACHE_EXPIRES && $expirationTime < time()) { + return true; + } + return false; + } +} diff --git a/lib/Migration/Version8004Date20260130131217.php b/lib/Migration/Version8004Date20260130131217.php new file mode 100644 index 0000000000..bc2312d8ce --- /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..4e26e72fc8 --- /dev/null +++ b/lib/Service/FederatedInvitesService.php @@ -0,0 +1,246 @@ +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 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_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(), + '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']) || $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 ContactAlreadyExistsException + */ + 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]; + $response = new JSONResponse($response, Http::STATUS_BAD_REQUEST); + $response->throttle(); + return $response; + } + + if ($invitation->isAccepted() === true) { + $response = ['message' => 'Invite already accepted', 'error' => true]; + return new JSONResponse($response, Http::STATUS_CONFLICT); + } + + if ($invitation->getExpiredAt() !== null && $updated > $invitation->getExpiredAt()) { + $response = ['message' => 'Invitation expired', 'error' => true]; + return new JSONResponse($response, Http::STATUS_BAD_REQUEST); + } + // 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]; + $response = new JSONResponse($response, Http::STATUS_BAD_REQUEST); + $response->throttle(); + return $response; + } + + $sharedFromEmail = $localUser->getEMailAddress(); + if ($sharedFromEmail === null) { + $response = ['message' => 'Invalid or non existing token', 'error' => true]; + $response = new JSONResponse($response, Http::STATUS_BAD_REQUEST); + $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 (ContactAlreadyExistsException $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/Social/FacebookProvider.php b/lib/Service/Social/FacebookProvider.php index 0591dd482a..ca1df506d1 100644 --- a/lib/Service/Social/FacebookProvider.php +++ b/lib/Service/Social/FacebookProvider.php @@ -7,6 +7,7 @@ namespace OCA\Contacts\Service\Social; +use OCP\AppFramework\Http; use OCP\Http\Client\IClient; use OCP\Http\Client\IClientService; @@ -120,7 +121,7 @@ protected function getProfileIds(array $contact):array { protected function findFacebookId(string $profileName):string { try { $result = $this->httpClient->get('https://facebook.com/' . $profileName); - if ($result->getStatusCode() !== 200) { + if ($result->getStatusCode() !== Http::STATUS_OK) { return $profileName; } $htmlResult = $result->getBody(); diff --git a/lib/Service/SocialApiService.php b/lib/Service/SocialApiService.php index 5af2607f62..58a254537a 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\ContactAlreadyExistsException; 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,10 @@ use OCP\Http\Client\IClientService; use OCP\IAddressBook; use OCP\IConfig; -use OCP\IL10N; +use OCP\ICreateContactFromString; use OCP\IURLGenerator; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; use function in_array; class SocialApiService { @@ -40,10 +44,10 @@ public function __construct( private IManager $manager, private IConfig $config, private IClientService $clientService, - private IL10N $l10n, private IURLGenerator $urlGen, private ITimeFactory $timeFactory, private ImageResizer $imageResizer, + private LoggerInterface $logger, ) { $this->appName = Application::APP_ID; } @@ -107,30 +111,22 @@ protected function addPhoto(array &$contact, string $imageType, string $photo) { /** * Gets the address book 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; } @@ -183,7 +179,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) { @@ -207,9 +207,12 @@ public function updateContact(string $addressBookId, string $contactId, ?string foreach ($urls as $url) { try { $httpResult = $this->clientService->newClient()->get($url); - $socialData = $httpResult->getBody(); - $imageType = $httpResult->getHeader('content-type'); - if (isset($socialData) && !empty($imageType)) { + $socialdata = $httpResult->getBody(); + $imageTypeHeader = $httpResult->getHeader('content-type'); + if ($imageTypeHeader !== '') { + $imageType = strtolower(trim(explode(';', $imageTypeHeader, 2)[0])); + } + if (isset($socialdata) && !empty($imageType)) { break; } } catch (\Exception $e) { @@ -252,7 +255,131 @@ public function updateContact(string $addressBookId, string $contactId, ?string } /** - * checks an address book is existing + * 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 ContactAlreadyExistsException + */ + 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 ContactAlreadyExistsException + $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 ContactAlreadyExistsException('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 (ContactAlreadyExistsException $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->getMessage(), ['exception' => $e]); + } + 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 * * @param string $searchBookId the UID of the addressbook to verify * @param string $userId the user that should have access 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..226a63ba9e --- /dev/null +++ b/lib/WayfProvider.php @@ -0,0 +1,184 @@ +meshProvidersCache->hasExpired(ConfigLexicon::FEDERATIONS_CACHE_EXPIRES)) { + $refresh = true; + } + + $federationsCache = $this->meshProvidersCache->getFederations(); + $urls = preg_split('/\s+/', trim($this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::MESH_PROVIDERS_SERVICE))); + $federations = []; + $ourServerUrlParts = parse_url($this->urlGenerator->getAbsoluteUrl('/')); + $ourFqdn = is_array($ourServerUrlParts) && isset($ourServerUrlParts['host']) ? $ourServerUrlParts['host'] : ''; + $found = []; + foreach ($urls as $url) { + if ($url === '') { + continue; + } + try { + $res = $this->httpClient->newClient()->get($url); + $code = $res->getStatusCode(); + if (!($code >= Http::STATUS_OK && $code < Http::STATUS_BAD_REQUEST)) { + $this->logger->error("Unable to retrieve providers from service at $url. Status returned was: " . $code, ['app' => Application::APP_ID]); + 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; + } + $cachedProvider = $this->getProviderFromCache($fqdn); + + // allways discover a new provider + if (empty($cachedProvider) || $refresh) { + $inviteAcceptDialog = ''; + 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, + ]; + } else { + // used cached data + $federations[$fed][] = $cachedProvider; + } + 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]); + } + } + $this->meshProvidersCache->setFederations($federations); + } + + /** + * Return the provider with the specified fqdn from the cache. + * + * @param string $fqdn + * @return array + */ + private function getProviderFromCache(string $fqdn = ''): array { + $federations = $this->meshProvidersCache->getFederations(); + $providerFound = null; + array_find($federations, function ($mesh) use ($fqdn, &$providerFound) { + if ($providerFound === null) { + $providerFound = array_find($mesh, function ($provider) use ($fqdn) { + return $provider['fqdn'] == $fqdn ? $provider['fqdn'] : null; + }); + } + return $providerFound; + }); + return $providerFound === null ? [] : $providerFound; + } + + /** + * Return the providers from the cache. + * + * @return array + */ + public function getMeshProviders(): array { + return $this->meshProvidersCache->getFederations(); + } + + /** + * 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..d4b2e2e12b 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -12,28 +12,66 @@ v-model="allowSocialSync" type="checkbox" class="checkbox" - @change="updateSetting('allowSocialSync')"> + @change="updateSocialSetting('allowSocialSync')">

+ +

{{ t('contacts', 'External invitations') }}

+

+ + +

+

+ + +

+ + diff --git a/src/components/AppNavigation/RootNavigation.vue b/src/components/AppNavigation/RootNavigation.vue index de60ab6ca3..5a09c668dc 100644 --- a/src/components/AppNavigation/RootNavigation.vue +++ b/src/components/AppNavigation/RootNavigation.vue @@ -94,6 +94,25 @@ + + + + + !invite.accepted).length + }, + // list all the contacts that doesn't have a group ungroupedContacts() { return this.sortedContacts.filter((contact) => this.contacts[contact.key]?.groups && this.contacts[contact.key]?.groups?.length === 0) @@ -422,7 +456,7 @@ export default { : t('contacts', 'Collapse teams') }, - ...mapStores(useUserGroupStore), + ...mapStores(useOcmInvitesStore, useUserGroupStore), }, methods: { @@ -526,6 +560,7 @@ $caption-padding: 22px; padding: calc(var(--default-grid-baseline, 4px) * 2); } +#external-invitations, #newgroup, #newcircle { margin-top: $caption-padding; diff --git a/src/components/AppNavigation/Settings/SettingsImportContacts.vue b/src/components/AppNavigation/Settings/SettingsImportContacts.vue index fd6f440d46..9a13ffb7e0 100644 --- a/src/components/AppNavigation/Settings/SettingsImportContacts.vue +++ b/src/components/AppNavigation/Settings/SettingsImportContacts.vue @@ -4,7 +4,9 @@ -->