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', 'Allow updating avatars from social media') }}
+
+ {{ t('contacts', 'External invitations') }}
+
+
+ {{ t('contacts', 'Allow creating invites without an email address (link-only)') }}
+
+
+
+ {{ t('contacts', 'Show the "Copy encoded invite" button on invite details') }}
+
+
+
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 @@
-->
-