diff --git a/appinfo/info.xml b/appinfo/info.xml
index 249c4c20b3..cb6c9d3adc 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -49,8 +49,13 @@
OCA\Contacts\Cron\SocialUpdateRegistration
+ OCA\Contacts\Cron\UpdateOcmProviders
+
+ OCA\Contacts\Command\OcmInvitesConfig
+
+
OCA\Contacts\Settings\AdminSettings
diff --git a/jest.config.cjs b/jest.config.cjs
index 168bf17c54..86088350fc 100644
--- a/jest.config.cjs
+++ b/jest.config.cjs
@@ -10,7 +10,7 @@ module.exports = {
preset: 'ts-jest',
moduleFileExtensions: ['js', 'vue', 'ts'],
collectCoverageFrom: [
- 'src/**/*.{js,vue}',
+ 'src/**/*.{js,ts,vue}',
'!**/node_modules/**',
],
setupFilesAfterEnv: [
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index ac43828a3c..6a951b2785 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -7,10 +7,13 @@
namespace OCA\Contacts\AppInfo;
use OCA\Contacts\Capabilities;
+use OCA\Contacts\ConfigLexicon;
use OCA\Contacts\Dav\PatchPlugin;
use OCA\Contacts\Event\LoadContactsOcaApiEvent;
+use OCA\Contacts\Listener\FederatedInviteAcceptedListener;
use OCA\Contacts\Listener\LoadContactsFilesActions;
use OCA\Contacts\Listener\LoadContactsOcaApi;
+use OCA\Contacts\Listener\OcmDiscoveryListener;
use OCA\DAV\Events\SabrePluginAddEvent;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCP\AppFramework\App;
@@ -18,6 +21,9 @@
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\EventDispatcher\IEventDispatcher;
+use OCP\OCM\Events\LocalOCMDiscoveryEvent;
+use OCP\OCM\Events\OCMEndpointRequestEvent;
+use OCP\OCM\Events\ResourceTypeRegisterEvent;
class Application extends App implements IBootstrap {
public const APP_ID = 'contacts';
@@ -33,8 +39,15 @@ public function __construct() {
#[\Override]
public function register(IRegistrationContext $context): void {
$context->registerCapability(Capabilities::class);
+ $context->registerConfigLexicon(ConfigLexicon::class);
+
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadContactsFilesActions::class);
$context->registerEventListener(LoadContactsOcaApiEvent::class, LoadContactsOcaApi::class);
+ $context->registerEventListener(OCMEndpointRequestEvent::class, FederatedInviteAcceptedListener::class);
+ $ocmDiscoveryEvent = class_exists(LocalOCMDiscoveryEvent::class)
+ ? LocalOCMDiscoveryEvent::class
+ : ResourceTypeRegisterEvent::class;
+ $context->registerEventListener($ocmDiscoveryEvent, OcmDiscoveryListener::class);
}
#[\Override]
diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php
new file mode 100644
index 0000000000..e0f6186aeb
--- /dev/null
+++ b/lib/ConfigLexicon.php
@@ -0,0 +1,109 @@
+requireOcmInvitesEnabled()) !== null) {
+ return $disabled;
+ }
+
+ $uid = $this->userSession->getUser()->getUID();
+ try {
+ $_invites = $this->federatedInviteMapper->findOpenInvitesByUid($uid);
+ $invites = [];
+ foreach ($_invites as $invite) {
+ if ($invite instanceof FederatedInvite) {
+ array_push(
+ $invites,
+ $invite->jsonSerialize()
+ );
+ }
+ }
+ return new JSONResponse($invites, Http::STATUS_OK);
+ } catch (Exception $e) {
+ $this->logger->error("An unexpected error occurred loading invites for user with uid=$uid. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse([
+ 'code' => 'ocm_invites_fetch_failed',
+ 'message' => 'Could not load invites.',
+ ], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ /**
+ * Deletes the invite with the specified token.
+ *
+ * @param string $token the token of the invite to delete
+ * @return JSONResponse with data signature ['token' | 'message'] - the token of the deleted invitation or an error message in case of error
+ */
+ #[NoAdminRequired]
+ #[FrontpageRoute(verb: 'DELETE', url: '/ocm/invitations/{token}')]
+ public function deleteInvite(string $token): JSONResponse {
+ if (($disabled = $this->requireOcmInvitesEnabled()) !== null) {
+ return $disabled;
+ }
+
+ $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
+ * @param string $note optional note/label for identifying the invite
+ * @param bool $ccSender whether to send a copy of the invite to the sender
+ * @return JSONResponse with data signature ['invite' | 'message'] - the invite url or an error message in case of error.
+ */
+ #[NoAdminRequired]
+ #[UserRateLimit(limit: 60, period: 3600)]
+ #[BruteForceProtection(action: 'ocmInviteCreate')]
+ #[FrontpageRoute(verb: 'POST', url: '/ocm/invitations')]
+ public function createInvite(string $email = '', string $message = '', string $note = '', bool $ccSender = false): JSONResponse {
+ if (($disabled = $this->requireOcmInvitesEnabled()) !== null) {
+ return $disabled;
+ }
+
+ // Enforce email required when optional mail is disabled
+ if (empty($email) && !$this->federatedInvitesService->isOptionalMailEnabled()) {
+ return new JSONResponse(['message' => $this->il10->t('Email address is required.')], Http::STATUS_BAD_REQUEST);
+ }
+
+ $uid = $this->userSession->getUser()->getUID();
+ if (!empty($email)) {
+ $validationError = $this->validateEmail($email);
+ if ($validationError !== null) {
+ return $validationError;
+ }
+
+ $this->cleanupSupersededInvitesForRecipient($uid, $email);
+
+ // check for existing open invite for the specified email, only if email provided
+ $existingInvites = $this->federatedInviteMapper->findOpenInvitesByRecipientEmail(
+ $uid,
+ $email,
+ );
+ if (count($existingInvites) > 0) {
+ $this->logger->error("An open invite already exists for user with uid $uid and for recipient email $email", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => $this->il10->t('An open invite already exists.')], Http::STATUS_CONFLICT);
+ }
+ }
+
+ $invite = new FederatedInvite();
+ $invite->setUserId($uid);
+ $token = UUIDUtil::getUUID();
+ $invite->setToken($token);
+ // created-/expiredAt in seconds
+ $invite->setCreatedAt($this->timeFactory->now()->getTimestamp());
+ $invite->setExpiredAt($this->federatedInvitesService->getInviteExpirationDate($invite->getCreatedAt()));
+ if (!empty($email)) {
+ $invite->setRecipientEmail($email);
+ }
+ // Store note in recipientName field (used as label until invite is accepted)
+ if (!empty($note)) {
+ $invite->setRecipientName($note);
+ }
+ $invite->setAccepted(false);
+ $inserted = false;
+ try {
+ $this->federatedInviteMapper->insert($invite);
+ $inserted = true;
+ } catch (Throwable $e) {
+ if ($this->isDuplicateConstraintException($e)) {
+ if (!empty($email) && $this->cleanupSupersededInvitesForRecipient($uid, $email) > 0) {
+ try {
+ $this->federatedInviteMapper->insert($invite);
+ $inserted = true;
+ } catch (Throwable $retry) {
+ if (!$this->isDuplicateConstraintException($retry)) {
+ $this->logger->error('An unexpected error occurred saving a new invite after stale cleanup. Stacktrace: ' . $retry->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred creating the invite.'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+ }
+ if (!$inserted) {
+ return new JSONResponse(['message' => $this->il10->t('An open invite already exists.')], Http::STATUS_CONFLICT);
+ }
+ } else {
+ $this->logger->error('An unexpected error occurred saving a new invite. Stacktrace: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred creating the invite.'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+ $senderProvider = $this->federatedInvitesService->getProviderFQDN();
+
+ // Only send email if email address provided
+ if (!empty($email)) {
+ /** @var JSONResponse */
+ $response = $this->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;
+ }
+
+ if ($ccSender && $this->federatedInvitesService->isCcSenderEnabled()) {
+ $response = $this->sendInvitationEmail($token, $senderProvider, $email, $message, true);
+ if ($response->getStatus() !== Http::STATUS_OK) {
+ $this->logger->error('Unable to send copy of email', ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'A copy of the invite could not be sent to you.'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+ }
+
+ // invite url, use token instead of email for routing
+ $inviteUrl = $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->linkToRoute('contacts.page.index') . 'ocm-invites/' . $token
+ );
+ return new JSONResponse(['invite' => $inviteUrl], Http::STATUS_OK);
+ }
+
+ /**
+ * Accepts the invite and creates a new contact from the inviter.
+ * On success the user is redirected to the new contact url.
+ *
+ * @param string $token the token of the invite
+ * @param string $provider the provider of the sender of the invite
+ * @return JSONResponse with data signature ['contact' | 'message'] - the new contact url or an error message in case of error
+ */
+ #[NoAdminRequired]
+ #[UserRateLimit(limit: 60, period: 3600)]
+ #[BruteForceProtection(action: 'ocmInviteAccept')]
+ #[FrontpageRoute(verb: 'PATCH', url: '/ocm/invitations/{token}/accept')]
+ public function acceptInvite(string $token = '', string $provider = ''): JSONResponse {
+ if (($disabled = $this->requireOcmInvitesEnabled()) !== null) {
+ return $disabled;
+ }
+
+ if ($token === '' || $provider === '') {
+ $this->logger->error("Both token and provider must be specified. Received: token=$token, provider=$provider", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'Both invite code and provider must be specified.'], Http::STATUS_BAD_REQUEST);
+ }
+ $localUser = $this->userSession->getUser();
+ if ($localUser === null) {
+ return new JSONResponse(['message' => $this->il10->t('Could not accept invite because no authenticated user was found.')], Http::STATUS_UNAUTHORIZED);
+ }
+ $provider = $this->normalizeProviderBase($provider);
+ if ($provider === null) {
+ return new JSONResponse(['message' => $this->il10->t('The invite provider is invalid or not allowed.')], Http::STATUS_BAD_REQUEST);
+ }
+ $recipientProvider = $this->federatedInvitesService->getProviderFQDN();
+ $userId = $localUser->getUID();
+ $email = $localUser->getEMailAddress();
+ $name = $localUser->getDisplayName();
+ if ($recipientProvider === '' || $userId === '' || $email === '' || $name === '') {
+ $this->logger->error("All of these must be set: recipientProvider: $recipientProvider, email: $email, userId: $userId, name: $name", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'Could not accept invite, user data is incomplete.'], Http::STATUS_UNPROCESSABLE_ENTITY);
+ }
+ $cloudId = '';
+ try {
+ // accept the invite by calling provider OCM /invite-accepted
+ // this returns a response with the following data signature: ['userID', 'email', 'name']
+ // @link https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post
+ $client = $this->httpClient->newClient();
+ /**
+ * @var \OCP\OCM\ICapabilityAwareOCMProvider $discovered
+ *
+ */
+ $discovered = $this->discovery->discover($provider);
+ $capabilities = $discovered->getCapabilities();
+ // Accept both the canonical advertised capability and older aliases.
+ if (
+ in_array('invites', $capabilities, true)
+ || in_array('invite-accepted', $capabilities, true)
+ || in_array('/invite-accepted', $capabilities, true)
+ ) {
+
+ $response = $this->discovery->requestRemoteOcmEndpoint(
+ null,
+ $provider,
+ '/invite-accepted',
+ [
+ 'recipientProvider' => $recipientProvider,
+ 'token' => $token,
+ 'userID' => $userId,
+ 'email' => $email,
+ 'name' => $name
+ ],
+ 'POST',
+ $client
+ );
+ $responseData = $response->getBody();
+ $data = json_decode($responseData, true);
+ if (
+ !is_array($data)
+ || !isset($data['userID'], $data['email'], $data['name'])
+ || !is_string($data['userID'])
+ || !is_string($data['email'])
+ || !is_string($data['name'])
+ || trim($data['userID']) === ''
+ || trim($data['email']) === ''
+ || trim($data['name']) === ''
+ ) {
+ $this->logger->warning('Invalid /invite-accepted payload from provider', [
+ 'app' => Application::APP_ID,
+ 'provider' => $provider,
+ 'payload' => $responseData,
+ ]);
+ return new JSONResponse(['message' => $this->il10->t('Could not accept invite because the remote provider returned an invalid response.')], Http::STATUS_BAD_GATEWAY);
+ }
+
+ $cloudId = $data['userID'] . '@' . $this->addressHandler->removeProtocolFromUrl($provider);
+
+ $contactRef = $this->federatedInvitesService->createNewContact(
+ $cloudId,
+ $data['email'],
+ $data['name'],
+ null
+ );
+ if (!isset($contactRef)) {
+ $this->logger->error('Remote invite acceptance succeeded but local contact creation failed', [
+ 'app' => Application::APP_ID,
+ 'token' => $token,
+ 'provider' => $provider,
+ 'cloudId' => $cloudId,
+ 'userId' => $userId,
+ ]);
+ return new JSONResponse([
+ 'code' => 'ocm_invite_local_contact_create_failed',
+ 'message' => $this->il10->t('The remote provider accepted the invite, but this server could not create the local contact.'),
+ ], Http::STATUS_BAD_GATEWAY);
+ }
+ $key = base64_encode($contactRef);
+ $contactUrl = $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->linkToRoute('contacts.page.index') . $this->il10->t('All contacts') . '/' . $key
+ );
+ return new JSONResponse(['contact' => $contactUrl], Http::STATUS_OK);
+ } else {
+ $this->logger->error('Provider: ' . $provider . ' does not support invites.', ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'Provider: ' . $provider . ' does not support invites.'], Http::STATUS_BAD_REQUEST);
+ }
+ } catch (ContactExistsException $e) {
+ return new JSONResponse(['message' => 'Contact with cloudID ' . $cloudId . ' already exists.'], Http::STATUS_CONFLICT);
+ } catch (RequestException $e) { // this should catch OCM API request exceptions
+ $this->logger->error('/invite-accepted returned an error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
+ /**
+ * 400: Invalid or non existing token
+ * 409: Invite already accepted
+ */
+ $statusCode = $e->getResponse() !== null
+ ? $e->getResponse()->getStatusCode()
+ : null;
+ switch ($statusCode) {
+ case Http::STATUS_BAD_REQUEST:
+ return new JSONResponse(['message' => 'Invalid, non-existing, or expired invite code.'], Http::STATUS_BAD_REQUEST);
+ case Http::STATUS_CONFLICT:
+ return new JSONResponse(['message' => 'Invite already accepted'], Http::STATUS_CONFLICT);
+ }
+ $this->logger->error("An unexpected error occurred accepting invite with token=$token and provider=$provider. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred trying to accept invite.'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ } catch (OCMProviderException|OCMRequestException|Exception $e) {
+ $this->logger->error("An unexpected error occurred accepting invite with token=$token and provider=$provider. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred trying to accept invite'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ /**
+ * Re-sends an existing invite email while preserving invite lifetime metadata.
+ *
+ */
+ #[NoAdminRequired]
+ #[UserRateLimit(limit: 30, period: 3600)]
+ #[BruteForceProtection(action: 'ocmInviteResend')]
+ #[FrontpageRoute(verb: 'PATCH', url: '/ocm/invitations/{token}/resend')]
+ public function resendInvite(string $token): JSONResponse {
+ if (($disabled = $this->requireOcmInvitesEnabled()) !== null) {
+ return $disabled;
+ }
+
+ $uid = $this->userSession->getUser()->getUID();
+ try {
+ $invite = $this->federatedInviteMapper->findInviteByTokenAndUid($token, $uid);
+ } catch (DoesNotExistException $e) {
+ $this->logger->warning("Could not find invite with token=$token for user with uid=$uid", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'Invite not found'], Http::STATUS_NOT_FOUND);
+ } catch (Exception $e) {
+ $this->logger->error("An unexpected error occurred loading invite with token=$token. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred trying to resend the invite'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ // Cannot resend if no email address
+ if (empty($invite->getRecipientEmail())) {
+ return new JSONResponse(['message' => $this->il10->t('Cannot resend: no email address')], Http::STATUS_UNPROCESSABLE_ENTITY);
+ }
+ if ($this->isInviteAccepted($invite)) {
+ return new JSONResponse([
+ 'code' => 'ocm_invite_already_accepted',
+ 'message' => $this->il10->t('Invite has already been accepted.'),
+ ], Http::STATUS_CONFLICT);
+ }
+ if ($this->isInviteExpired($invite)) {
+ return new JSONResponse([
+ 'code' => 'ocm_invite_expired',
+ 'message' => $this->il10->t('Invite has expired. Please create a new one.'),
+ ], Http::STATUS_GONE);
+ }
+
+ $sendDate = date('Y-m-d', $invite->getCreatedAt());
+ $initiatorDisplayName = $this->userSession->getUser()->getDisplayName();
+ // a resend notification that refers to the previously sent invite
+ $message = $this->il10->t(
+ 'This is a copy of an invite sent to you previously by %1$s on %2$s',
+ [
+ $initiatorDisplayName,
+ $sendDate
+ ]
+ );
+ $senderProvider = $this->federatedInvitesService->getProviderFQDN();
+ /** @var JSONResponse */
+ $response = $this->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((string)$provider->getInviteAcceptDialog());
+ $absolute = $dialog === '' ? null : $this->buildInviteAcceptDialogAbsolute($base, $dialog);
+ if ($absolute === null) {
+ $dialog = $this->wayfProvider->getInviteAcceptDialogPath();
+ $absolute = $this->buildInviteAcceptDialogAbsolute($base, $dialog);
+ }
+ if ($absolute === null) {
+ return new DataResponse(['error' => 'OCM discovery failed', 'base' => $base], Http::STATUS_NOT_FOUND);
+ }
+
+ $baseHost = parse_url($base, PHP_URL_HOST);
+ return new DataResponse([
+ 'base' => $base,
+ 'inviteAcceptDialog' => $dialog,
+ 'inviteAcceptDialogAbsolute' => $absolute,
+ 'providerDomain' => is_string($baseHost) ? $baseHost : '',
+ ], Http::STATUS_OK);
+ } catch (Throwable $e) {
+ $this->logger->warning('OCM discovery failed', [
+ 'app' => Application::APP_ID,
+ 'base' => $base,
+ 'exception' => $e,
+ ]);
+ return new DataResponse(['error' => 'OCM discovery failed', 'base' => $base], Http::STATUS_NOT_FOUND);
+ }
+ }
+
+ /**
+ * Accepts the invite and creates a new contact from the inviter.
+ * On success the user is redirected to the new contact url.
+ *
+ * @param string $token the token of the invite
+ * @return TemplateResponse the WAYF page
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[FrontpageRoute(verb: 'GET', url: '/wayf')]
+ public function wayf(string $token = ''): TemplateResponse {
+ Util::addScript(Application::APP_ID, 'contacts-wayf');
+ Util::addStyle(Application::APP_ID, 'contacts-wayf');
+ try {
+ $federations = $this->wayfProvider->getMeshProvidersFromCache();
+ $providerDomain = trim((string)$this->request->getParam('providerDomain', ''));
+ if ($providerDomain === '') {
+ $baseHost = parse_url($this->urlGenerator->getBaseUrl(), PHP_URL_HOST);
+ $providerDomain = is_string($baseHost) ? $baseHost : '';
+ }
+ $this->initialState->provideInitialState('wayf', [
+ 'federations' => $federations,
+ 'providerDomain' => $providerDomain,
+ 'token' => $token,
+ ]);
+ } catch (Exception $e) {
+ $this->logger->error($e->getMessage() . ' Trace: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ }
+ $template = new TemplateResponse('contacts', 'wayf', [], TemplateResponse::RENDER_AS_GUEST);
+ return $template;
+ }
+
+ /**
+ * 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((string)$parts['scheme']);
+ if (!in_array($scheme, ['http', 'https'], true)) {
+ return null;
+ }
+
+ $host = strtolower((string)$parts['host']);
+ if ($host === '') {
+ return null;
+ }
+ if (!$this->federatedInvitesService->isSsrfGuardDisabled() && $this->isBlockedDiscoveryHost($host)) {
+ return null;
+ }
+
+ $port = '';
+ if (isset($parts['port'])) {
+ $portNumber = (int)$parts['port'];
+ if ($portNumber < 1 || $portNumber > 65535) {
+ return null;
+ }
+ $port = ':' . $portNumber;
+ }
+
+ $path = '';
+ if (isset($parts['path']) && is_string($parts['path']) && $parts['path'] !== '') {
+ $path = '/' . trim($parts['path'], '/');
+ $path = rtrim($path, '/');
+ }
+
+ return $scheme . '://' . $host . $port . $path;
+ }
+
+ private function isBlockedDiscoveryHost(string $host): bool {
+ $normalizedHost = strtolower(trim($host));
+ if ($normalizedHost === 'localhost' || str_ends_with($normalizedHost, '.localhost')) {
+ return true;
+ }
+
+ if (filter_var($normalizedHost, FILTER_VALIDATE_IP) === false) {
+ return false;
+ }
+
+ return filter_var(
+ $normalizedHost,
+ FILTER_VALIDATE_IP,
+ FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
+ ) === false;
+ }
+
+ private function buildInviteAcceptDialogAbsolute(string $base, string $dialog): ?string {
+ $trimmedDialog = trim($dialog);
+ if ($trimmedDialog === '') {
+ return null;
+ }
+
+ $baseParts = parse_url($base);
+ if (!is_array($baseParts) || !isset($baseParts['scheme'], $baseParts['host'])) {
+ return null;
+ }
+
+ if (preg_match('#^https?://#i', $trimmedDialog)) {
+ $dialogUrl = $this->normalizeProviderBase($trimmedDialog);
+ if ($dialogUrl === null) {
+ return null;
+ }
+ $dialogParts = parse_url($dialogUrl);
+ if (!is_array($dialogParts) || !isset($dialogParts['host'])) {
+ return null;
+ }
+
+ $basePort = $baseParts['port'] ?? null;
+ $dialogPort = $dialogParts['port'] ?? null;
+ if (strtolower((string)$dialogParts['host']) !== strtolower((string)$baseParts['host']) || $basePort !== $dialogPort) {
+ return null;
+ }
+
+ return $dialogUrl;
+ }
+
+ $origin = $baseParts['scheme'] . '://' . $baseParts['host'];
+ if (isset($baseParts['port'])) {
+ $origin .= ':' . $baseParts['port'];
+ }
+
+ if (str_starts_with($trimmedDialog, '/')) {
+ return $origin . $trimmedDialog;
+ }
+
+ return rtrim($base, '/') . '/' . ltrim($trimmedDialog, '/');
+ }
+
+ private function buildWayfInviteLink(string $wayfEndpoint, string $token, string $senderProvider): string {
+ $separator = str_contains($wayfEndpoint, '?') ? '&' : '?';
+ $query = http_build_query([
+ 'token' => $token,
+ 'providerDomain' => $senderProvider,
+ ], '', '&', PHP_QUERY_RFC3986);
+ return $wayfEndpoint . $separator . $query;
+ }
+
+ private function requireOcmInvitesEnabled(): ?JSONResponse {
+ if ($this->federatedInvitesService->isOcmInvitesEnabled()) {
+ return null;
+ }
+
+ return new JSONResponse([
+ 'code' => 'ocm_invites_disabled',
+ 'message' => $this->il10->t('OCM invites are disabled.'),
+ ], Http::STATUS_FORBIDDEN);
+ }
+
+ private function cleanupSupersededInvitesForRecipient(string $uid, string $email): int {
+ if ($email === '') {
+ return 0;
+ }
+
+ try {
+ return $this->federatedInviteMapper->deleteSupersededInvitesForRecipientEmail(
+ $uid,
+ $email,
+ $this->timeFactory->now()->getTimestamp(),
+ );
+ } catch (Exception $e) {
+ $this->logger->warning('Could not clean up superseded invites for recipient email.', [
+ 'app' => Application::APP_ID,
+ 'userId' => $uid,
+ 'email' => $email,
+ 'exception' => $e,
+ ]);
+ return 0;
+ }
+ }
+
+ private function isInviteAccepted(FederatedInvite $invite): bool {
+ return $invite->isAccepted() === true || $invite->getAcceptedAt() !== null;
+ }
+
+ private function isInviteExpired(FederatedInvite $invite): bool {
+ $expiredAt = $invite->getExpiredAt();
+ return $expiredAt !== null && $expiredAt <= $this->timeFactory->now()->getTimestamp();
+ }
+
+ private function isDuplicateConstraintException(Throwable $e): bool {
+ $message = strtolower($e->getMessage());
+ return str_contains($message, 'duplicate')
+ || str_contains($message, 'unique')
+ || str_contains($message, 'constraint');
+ }
+}
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index 6d7ce77fc0..f6900f2580 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();
@@ -67,6 +78,8 @@ public function index(): TemplateResponse {
$isTalkEnabled = $this->appManager->isEnabledForUser('spreed') === true;
$isTalkVersionCompatible = $this->compareVersion->isCompatible($talkVersion ? $talkVersion : '0.0.0', 2);
+ $isOcmInvitesEnabled = $this->federatedInvitesService->isOcmInvitesEnabled();
+ $ocmInvitesConfig = $this->federatedInvitesService->getOcmInvitesConfig();
$this->initialState->provideInitialState('isGroupSharingEnabled', $isGroupSharingEnabled);
$this->initialState->provideInitialState('locales', $locales);
@@ -77,6 +90,8 @@ public function index(): TemplateResponse {
$this->initialState->provideInitialState('isContactsInteractionEnabled', $isContactsInteractionEnabled);
$this->initialState->provideInitialState('isCirclesEnabled', $isCirclesEnabled && $isCircleVersionCompatible);
$this->initialState->provideInitialState('isTalkEnabled', $isTalkEnabled && $isTalkVersionCompatible);
+ $this->initialState->provideInitialState('isOcmInvitesEnabled', $isOcmInvitesEnabled);
+ $this->initialState->provideInitialState('ocmInvitesConfig', $ocmInvitesConfig);
Util::addStyle(Application::APP_ID, 'contacts-main');
Util::addScript(Application::APP_ID, 'contacts-main');
diff --git a/lib/Cron/UpdateOcmProviders.php b/lib/Cron/UpdateOcmProviders.php
new file mode 100644
index 0000000000..bdcec71df0
--- /dev/null
+++ b/lib/Cron/UpdateOcmProviders.php
@@ -0,0 +1,37 @@
+setInterval($this->expire_time);
+ }
+
+ #[\Override]
+ protected function run($argument) {
+ $data = $this->wayfProvider->getMeshProviders();
+ $data['expires'] = time() + $this->expire_time;
+ $this->appConfig->setValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, $data, true);
+ }
+}
diff --git a/lib/Db/FederatedInvite.php b/lib/Db/FederatedInvite.php
new file mode 100644
index 0000000000..ad757ec7cf
--- /dev/null
+++ b/lib/Db/FederatedInvite.php
@@ -0,0 +1,78 @@
+addType('accepted', Types::BOOLEAN);
+ $this->addType('acceptedAt', Types::BIGINT);
+ $this->addType('createdAt', Types::BIGINT);
+ $this->addType('expiredAt', Types::BIGINT);
+ $this->addType('recipientEmail', Types::STRING);
+ $this->addType('recipientName', Types::STRING);
+ $this->addType('recipientProvider', Types::STRING);
+ $this->addType('recipientUserId', Types::STRING);
+ $this->addType('token', Types::STRING);
+ $this->addType('userId', Types::STRING);
+ }
+
+ public function jsonSerialize(): array {
+ return [
+ 'accepted' => $this->accepted,
+ 'acceptedAt' => $this->acceptedAt,
+ 'createdAt' => $this->createdAt,
+ 'expiredAt' => $this->expiredAt,
+ 'recipientEmail' => $this->recipientEmail,
+ 'recipientName' => $this->recipientName,
+ 'recipientProvider' => $this->recipientProvider,
+ 'recipientUserId' => $this->recipientUserId,
+ 'token' => $this->token,
+ 'userId' => $this->userId,
+ ];
+ }
+
+}
diff --git a/lib/Db/FederatedInviteMapper.php b/lib/Db/FederatedInviteMapper.php
new file mode 100644
index 0000000000..39e02025a1
--- /dev/null
+++ b/lib/Db/FederatedInviteMapper.php
@@ -0,0 +1,183 @@
+
+ */
+class FederatedInviteMapper extends QBMapper {
+ public const TABLE_NAME = 'federated_invites';
+
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, self::TABLE_NAME);
+ }
+
+ /**
+ * Returns the federated invite with the specified token
+ *
+ * @return FederatedInvite
+ */
+ public function findByToken(string $token): FederatedInvite {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from(self::TABLE_NAME)
+ ->where($qb->expr()->eq('token', $qb->createNamedParameter($token)));
+ return $this->findEntity($qb);
+ }
+
+ /**
+ * Returns all open federated invites for the user with the specified user id
+ *
+ * @return list
+ */
+ public function findOpenInvitesByUid(string $userId, ?int $now = null): array {
+ $timestamp = $now ?? time();
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from(self::TABLE_NAME)
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->andWhere($qb->expr()->isNull('accepted_at'))
+ ->andWhere(
+ $qb->expr()->orX(
+ $qb->expr()->isNull('expired_at'),
+ $qb->expr()->gt('expired_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)),
+ ),
+ );
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * Returns all open federated invites for the user with the specified user id and for the specified recipient email
+ *
+ * @return list
+ */
+ public function findOpenInvitesByRecipientEmail(string $userId, string $email, ?int $now = null): array {
+ $timestamp = $now ?? time();
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from(self::TABLE_NAME)
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('recipient_email', $qb->createNamedParameter($email)))
+ ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->andWhere($qb->expr()->isNull('accepted_at'))
+ ->andWhere(
+ $qb->expr()->orX(
+ $qb->expr()->isNull('expired_at'),
+ $qb->expr()->gt('expired_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)),
+ ),
+ );
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * Returns the federated invite with the specified token for the user with the specified user id.
+ *
+ * @return FederatedInvite the matching invite
+ */
+ public function findInviteByTokenAndUid(string $token, string $userId):FederatedInvite {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from(self::TABLE_NAME)
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR)));
+ return $this->findEntity($qb);
+ }
+
+ /**
+ * Atomically claims an unclaimed (recipient_email IS NULL) and unaccepted
+ * invite for the given email and refreshes its lifetime.
+ *
+ * Returns true when exactly one row was affected, meaning the caller now
+ * owns the (token, recipient_email) pair. Returns false when the row no
+ * longer matches the precondition (a concurrent attach already claimed
+ * the invite, the invite was accepted, or the row vanished).
+ */
+ public function claimInviteForEmail(
+ string $token,
+ string $userId,
+ string $email,
+ int $createdAt,
+ int $expiredAt,
+ ): bool {
+ $qb = $this->db->getQueryBuilder();
+ $qb->update(self::TABLE_NAME)
+ ->set('recipient_email', $qb->createNamedParameter($email))
+ ->set('created_at', $qb->createNamedParameter($createdAt, IQueryBuilder::PARAM_INT))
+ ->set('expired_at', $qb->createNamedParameter($expiredAt, IQueryBuilder::PARAM_INT))
+ ->where($qb->expr()->eq('token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR)))
+ ->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->isNull('recipient_email'))
+ ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->andWhere($qb->expr()->isNull('accepted_at'));
+ return $qb->executeStatement() === 1;
+ }
+
+ /**
+ * Best-effort revert of a previous claim made by claimInviteForEmail().
+ * Scoped to the same sender (user_id) and only takes effect when the row
+ * still has the email we set and is still unaccepted, so the revert
+ * cannot undo a successful accept and cannot run if a concurrent attach
+ * changed recipient_email between the claim and the revert.
+ *
+ * Returns true when the revert took effect (exactly one row updated).
+ */
+ public function revertInviteEmail(
+ string $token,
+ string $userId,
+ string $email,
+ int $previousCreatedAt,
+ ?int $previousExpiredAt,
+ ): bool {
+ $qb = $this->db->getQueryBuilder();
+ $expiredParam = $previousExpiredAt === null
+ ? $qb->createNamedParameter(null, IQueryBuilder::PARAM_NULL)
+ : $qb->createNamedParameter($previousExpiredAt, IQueryBuilder::PARAM_INT);
+ $qb->update(self::TABLE_NAME)
+ ->set('recipient_email', $qb->createNamedParameter(null, IQueryBuilder::PARAM_NULL))
+ ->set('created_at', $qb->createNamedParameter($previousCreatedAt, IQueryBuilder::PARAM_INT))
+ ->set('expired_at', $expiredParam)
+ ->where($qb->expr()->eq('token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR)))
+ ->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('recipient_email', $qb->createNamedParameter($email)))
+ ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->andWhere($qb->expr()->isNull('accepted_at'));
+ return $qb->executeStatement() === 1;
+ }
+
+ /**
+ * Deletes invites that can no longer be acted on but would still block a
+ * fresh invite for the same recipient email. This covers expired rows and
+ * defensive cleanup for rows that already have an accepted_at timestamp.
+ */
+ public function deleteSupersededInvitesForRecipientEmail(string $userId, string $email, ?int $now = null): int {
+ $timestamp = $now ?? time();
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(self::TABLE_NAME)
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('recipient_email', $qb->createNamedParameter($email)))
+ ->andWhere(
+ $qb->expr()->orX(
+ $qb->expr()->isNotNull('accepted_at'),
+ $qb->expr()->andX(
+ $qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)),
+ $qb->expr()->isNotNull('expired_at'),
+ $qb->expr()->lte('expired_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)),
+ ),
+ ),
+ );
+ return $qb->executeStatement();
+ }
+
+}
diff --git a/lib/Exception/ContactExistsException.php b/lib/Exception/ContactExistsException.php
new file mode 100644
index 0000000000..98b7fd0ad9
--- /dev/null
+++ b/lib/Exception/ContactExistsException.php
@@ -0,0 +1,15 @@
+
+ */
+class FederatedInviteAcceptedListener implements IEventListener {
+
+ public function __construct(
+ private FederatedInvitesService $federatedInvitesService,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * Handles the OCMEndpointRequestEvent that is dispatched by the
+ * OCMRequestController as response to an OCM request. This handler manages
+ * the invite-accepted capability.
+ */
+ #[\Override]
+ public function handle(Event $event): void {
+ if (!($event instanceof OCMEndpointRequestEvent)
+ || $event->getRequestedCapability() !== 'invite-accepted') {
+ return;
+ }
+
+ $payload = $event->getPayload();
+ if (!$this->isValidInviteAcceptedPayload($payload)) {
+ $this->logger->error('Could not accept invite, user data is incomplete.', [
+ 'app' => Application::APP_ID,
+ 'payloadKeys' => array_keys($payload),
+ ]);
+ $event->setResponse(new JSONResponse([
+ 'message' => 'Could not accept invite, user data is incomplete.',
+ ], Http::STATUS_NOT_FOUND));
+ return;
+ }
+
+ $event->setResponse($this->federatedInvitesService->inviteAccepted(
+ $payload['recipientProvider'],
+ $payload['token'],
+ $payload['userID'],
+ $payload['email'],
+ $payload['name'],
+ ));
+ }
+
+ /**
+ * The accepted-invite callback requires all documented OCM string fields to
+ * be present and non-empty.
+ *
+ * @param array $payload
+ */
+ private function isValidInviteAcceptedPayload(array $payload): bool {
+ foreach (['recipientProvider', 'token', 'userID', 'email', 'name'] as $key) {
+ if (!array_key_exists($key, $payload) || !is_string($payload[$key])) {
+ return false;
+ }
+ }
+
+ return trim($payload['recipientProvider']) !== ''
+ && trim($payload['token']) !== ''
+ && trim($payload['userID']) !== ''
+ && trim($payload['email']) !== ''
+ && trim($payload['name']) !== '';
+ }
+}
diff --git a/lib/Listener/OcmDiscoveryListener.php b/lib/Listener/OcmDiscoveryListener.php
new file mode 100644
index 0000000000..722ea518a8
--- /dev/null
+++ b/lib/Listener/OcmDiscoveryListener.php
@@ -0,0 +1,69 @@
+ */
+class OcmDiscoveryListener implements IEventListener {
+ public function __construct(
+ private IAppConfig $appConfig,
+ private IURLGenerator $urlGenerator,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * Register the invite capability and dialog route on local OCM discovery.
+ *
+ * @param Event $event an event of type LocalOCMDiscoveryEvent or ResourceTypeRegisterEvent
+ * @return void
+ */
+ #[\Override]
+ public function handle(Event $event): void {
+ if (!$this->isOcmDiscoveryEvent($event)) {
+ return;
+ }
+
+ if (!$this->appConfig->getValueBool(Application::APP_ID, ContactsConfigLexicon::OCM_INVITES_ENABLED)) {
+ return;
+ }
+
+ if ($event instanceof LocalOCMDiscoveryEvent) {
+ $event->addCapability('invite-accepted');
+
+ try {
+ $event->getProvider()->setInviteAcceptDialog(
+ $this->urlGenerator->linkToRouteAbsolute(FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE_NAME),
+ );
+ } catch (Throwable $e) {
+ $this->logger->warning('OCM invites are enabled but invite accept dialog route cannot be resolved', [
+ 'app' => Application::APP_ID,
+ 'route' => FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE_NAME,
+ 'exception' => $e,
+ ]);
+ }
+ }
+ }
+
+ private function isOcmDiscoveryEvent(Event $event): bool {
+ return $event instanceof LocalOCMDiscoveryEvent
+ || $event instanceof ResourceTypeRegisterEvent;
+ }
+}
diff --git a/lib/Migration/Version8004Date20260130131217.php b/lib/Migration/Version8004Date20260130131217.php
new file mode 100644
index 0000000000..709e78761a
--- /dev/null
+++ b/lib/Migration/Version8004Date20260130131217.php
@@ -0,0 +1,92 @@
+hasTable($table_name)) {
+ $table = $schema->createTable($table_name);
+ $table->addColumn('id', Types::BIGINT, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'length' => 11,
+ 'unsigned' => true,
+ ]);
+ $table->addColumn('user_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 64,
+
+ ]);
+ // https://saturncloud.io/blog/what-is-the-maximum-length-of-a-url-in-different-browsers/#maximum-url-length-in-different-browsers
+ // We use the least common denominator, the minimum length supported by browsers
+ $table->addColumn('recipient_provider', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 2083,
+ ]);
+ $table->addColumn('recipient_user_id', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 1024,
+ ]);
+ $table->addColumn('recipient_name', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 1024,
+ ]);
+ // https://www.directedignorance.com/blog/maximum-length-of-email-address
+ $table->addColumn('recipient_email', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 320,
+ ]);
+ $table->addColumn('token', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 60,
+ ]);
+ $table->addColumn('accepted', Types::BOOLEAN, [
+ 'notnull' => false,
+ 'default' => false
+ ]);
+ $table->addColumn('created_at', Types::BIGINT, [
+ 'notnull' => true,
+ ]);
+
+ $table->addColumn('expired_at', Types::BIGINT, [
+ 'notnull' => false,
+ ]);
+
+ $table->addColumn('accepted_at', Types::BIGINT, [
+ 'notnull' => false,
+ ]);
+
+ $table->addUniqueConstraint(['token']);
+ $table->setPrimaryKey(['id']);
+ return $schema;
+ }
+
+ return null;
+ }
+}
diff --git a/lib/Migration/Version8005Date20260418120000.php b/lib/Migration/Version8005Date20260418120000.php
new file mode 100644
index 0000000000..18a6577ca5
--- /dev/null
+++ b/lib/Migration/Version8005Date20260418120000.php
@@ -0,0 +1,127 @@
+connection->getQueryBuilder();
+ $qb->update('federated_invites')
+ ->set('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
+ ->where($qb->expr()->isNull('accepted'));
+ $qb->executeStatement();
+ }
+
+ #[\Override]
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ if (!$schema->hasTable('federated_invites')) {
+ return null;
+ }
+
+ $table = $schema->getTable('federated_invites');
+ if (!$table->hasColumn('accepted')) {
+ return null;
+ }
+
+ $column = $table->getColumn('accepted');
+ if ($column->getNotnull() === true) {
+ return null;
+ }
+
+ $column->setNotnull(true);
+ $column->setDefault(false);
+
+ return $schema;
+ }
+
+ #[\Override]
+ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ $this->createPartialUniqueIndex($output);
+ }
+
+ private function createPartialUniqueIndex(IOutput $output): void {
+ $provider = $this->connection->getDatabaseProvider();
+ if ($provider !== IDBConnection::PLATFORM_POSTGRES && $provider !== IDBConnection::PLATFORM_SQLITE) {
+ $output->info(sprintf(
+ 'Skipping partial unique index %s on %s; application-level guard remains in effect.',
+ self::INDEX_NAME,
+ $provider,
+ ));
+ $this->logger->info(
+ 'Skipped partial unique index for federated_invites on database provider {provider}.',
+ ['app' => 'contacts', 'provider' => $provider],
+ );
+ return;
+ }
+
+ $predicate = $provider === IDBConnection::PLATFORM_POSTGRES
+ ? 'recipient_email IS NOT NULL AND accepted = false'
+ : 'recipient_email IS NOT NULL AND accepted = 0';
+
+ $sql = sprintf(
+ 'CREATE UNIQUE INDEX IF NOT EXISTS %s ON %sfederated_invites (user_id, recipient_email) WHERE %s',
+ self::INDEX_NAME,
+ '*PREFIX*',
+ $predicate,
+ );
+
+ try {
+ $this->connection->executeStatement($sql);
+ } catch (\Throwable $e) {
+ $this->logger->warning(
+ 'Failed to create partial unique index for federated_invites: {message}',
+ ['app' => 'contacts', 'message' => $e->getMessage(), 'exception' => $e],
+ );
+ throw new \RuntimeException('Could not create required open-invite uniqueness index.', 0, $e);
+ }
+ }
+
+}
diff --git a/lib/Service/FederatedInvitesService.php b/lib/Service/FederatedInvitesService.php
new file mode 100644
index 0000000000..bb4a12503c
--- /dev/null
+++ b/lib/Service/FederatedInvitesService.php
@@ -0,0 +1,257 @@
+appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_ENABLED);
+ }
+
+ public function isOptionalMailEnabled(): bool {
+ return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_OPTIONAL_MAIL);
+ }
+
+ public function isCcSenderEnabled(): bool {
+ return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_CC_SENDER);
+ }
+
+ public function isEncodedCopyButtonEnabled(): bool {
+ return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_ENCODED_COPY_BUTTON);
+ }
+
+ public function isSsrfGuardDisabled(): bool {
+ return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_DISABLE_SSRF_GUARD);
+ }
+
+ /**
+ * The set of admin-toggleable OCM bool keys. Used to gate writes from the
+ * admin settings page so callers cannot persist arbitrary keys.
+ */
+ public const OCM_INVITES_BOOL_KEYS = [
+ ConfigLexicon::OCM_INVITES_OPTIONAL_MAIL,
+ ConfigLexicon::OCM_INVITES_CC_SENDER,
+ ConfigLexicon::OCM_INVITES_ENCODED_COPY_BUTTON,
+ ConfigLexicon::OCM_INVITES_DISABLE_SSRF_GUARD,
+ ];
+
+ /**
+ * Persist an OCM admin bool toggle. Returns true when the key is allowed.
+ */
+ public function setOcmInviteBoolSetting(string $key, bool $value): bool {
+ if (!in_array($key, self::OCM_INVITES_BOOL_KEYS, true)) {
+ return false;
+ }
+ $this->appConfig->setValueBool(Application::APP_ID, $key, $value);
+ return true;
+ }
+
+ /**
+ * Returns all OCM invites config flags for frontend consumption
+ */
+ public function getOcmInvitesConfig(): array {
+ return [
+ 'optionalMail' => $this->isOptionalMailEnabled(),
+ 'ccSender' => $this->isCcSenderEnabled(),
+ 'encodedCopyButton' => $this->isEncodedCopyButtonEnabled(),
+ ];
+ }
+
+ /**
+ * Returns the provider's server FQDN.
+ * @return string the FQDN
+ */
+ public function getProviderFQDN(): string {
+ $serverUrl = $this->urlGenerator->getAbsoluteURL('/');
+ $parts = parse_url($serverUrl);
+ if (!is_array($parts) || !isset($parts['host']) || !is_string($parts['host'])) {
+ return '';
+ }
+ return $parts['host'];
+ }
+
+ /**
+ * Returns the expiration date.
+ * @param int $creationDate
+ * @return int the expiration date
+ */
+ public function getInviteExpirationDate(int $creationDate): int {
+ return $creationDate + self::INVITE_EXPIRATION_PERIOD_SECONDS;
+ }
+
+ /**
+ * Creates a new contact and adds it to the address book of the user with the specified userId or,
+ * if null, the current logged-in user.
+ *
+ * @param string cloudId
+ * @param string email
+ * @param string name
+ * @param ?string userId id of the user for which to create the new contact.
+ * If null, this is the current logged-in user.
+ *
+ * @return string the ref of the new contact in the form
+ * 'contactURI~addressBookUri'
+ * @throws ContactExistsException
+ */
+ public function createNewContact(string $cloudId, string $email, string $name, ?string $userId): ?string {
+ $localUserId = $userId ? $userId : $this->userSession->getUser()->getUID();
+ $newContact = $this->socialApiService->createContact(
+ $cloudId,
+ $email,
+ $name,
+ $localUserId,
+ );
+ if (!isset($newContact)) {
+ $this->logger->error('Error creating contact for user {userId} with cloud id {cloudId}.', [
+ 'app' => Application::APP_ID,
+ 'userId' => $localUserId,
+ 'cloudId' => $cloudId,
+ ]);
+ return null;
+ }
+ $this->logger->info('Created new contact with UID: ' . $newContact['UID'] . ' for user with UID: ' . $localUserId, ['app' => Application::APP_ID]);
+ $addressBookUri = CardDavBackend::PERSONAL_ADDRESSBOOK_URI;
+ if (isset($newContact['ADDRESSBOOK_URI']) && is_string($newContact['ADDRESSBOOK_URI']) && $newContact['ADDRESSBOOK_URI'] !== '') {
+ $addressBookUri = $newContact['ADDRESSBOOK_URI'];
+ }
+ $contactRef = $newContact['UID'] . '~' . $addressBookUri;
+ return $contactRef;
+ }
+
+ /**
+ * This is the invite-accepted capability implementation.
+ */
+ public function inviteAccepted(string $recipientProvider, string $token, string $userID, string $email, string $name): JSONResponse {
+ $this->logger->debug('Processing share invitation for ' . $userID . ' with token ' . $token . ' and email ' . $email . ' and name ' . $name);
+
+ $updated = $this->timeFactory->getTime();
+
+ if ($token === '') {
+ $response = new JSONResponse(['message' => 'Invalid or non existing token', 'error' => true], Http::STATUS_BAD_REQUEST);
+ $response->throttle();
+ return $response;
+ }
+
+ if (trim($recipientProvider) === '' || trim($userID) === '' || trim($email) === '' || trim($name) === '') {
+ return new JSONResponse(['message' => 'Could not accept invite, user data is incomplete.', 'error' => true], Http::STATUS_BAD_REQUEST);
+ }
+
+ try {
+ $invitation = $this->federatedInviteMapper->findByToken($token);
+ } catch (DoesNotExistException) {
+ $response = ['message' => 'Invalid or non existing token', 'error' => true];
+ $status = Http::STATUS_BAD_REQUEST;
+ $response = new JSONResponse($response, $status);
+ $response->throttle();
+ return $response;
+ }
+
+ if ($invitation->isAccepted() === true) {
+ $response = ['message' => 'Invite already accepted', 'error' => true];
+ $status = Http::STATUS_CONFLICT;
+ return new JSONResponse($response, $status);
+ }
+
+ if ($invitation->getExpiredAt() !== null && $updated > $invitation->getExpiredAt()) {
+ $response = ['message' => 'Invitation expired', 'error' => true];
+ $status = Http::STATUS_BAD_REQUEST;
+ return new JSONResponse($response, $status);
+ }
+ // Note that there is no user session; local user is the sender of the invite
+ $localUser = $this->userManager->get($invitation->getUserId());
+ if ($localUser === null) {
+ $response = ['message' => 'Invalid or non existing token', 'error' => true];
+ $status = Http::STATUS_BAD_REQUEST;
+ $response = new JSONResponse($response, $status);
+ $response->throttle();
+ return $response;
+ }
+
+ $sharedFromEmail = $localUser->getEMailAddress();
+ if ($sharedFromEmail === null) {
+ $response = ['message' => 'Invalid or non existing token', 'error' => true];
+ $status = Http::STATUS_BAD_REQUEST;
+ $response = new JSONResponse($response, $status);
+ $response->throttle();
+ return $response;
+ }
+ $sharedFromDisplayName = $localUser->getDisplayName();
+
+ $response = ['userID' => $localUser->getUID(), 'email' => $sharedFromEmail, 'name' => $sharedFromDisplayName];
+ $status = Http::STATUS_OK;
+
+ $cloudId = $userID . '@' . $this->addressHandler->removeProtocolFromUrl($recipientProvider);
+ try {
+ $contactRef = $this->createNewContact(
+ $cloudId,
+ $email,
+ $name,
+ $localUser->getUID()
+ );
+ if ($contactRef === null) {
+ $this->logger->error('Could not create sender-side contact after invite acceptance.', [
+ 'app' => Application::APP_ID,
+ 'userId' => $localUser->getUID(),
+ 'cloudId' => $cloudId,
+ 'token' => $token,
+ ]);
+ return new JSONResponse([
+ 'message' => 'Could not create sender-side contact after invite acceptance.',
+ 'error' => true,
+ ], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ } catch (ContactExistsException $e) {
+ // A duplicate sender-side contact should not block invite acceptance.
+ $this->logger->info("Contact with cloud id $cloudId already exists. ");
+ }
+
+ $invitation->setAccepted(true);
+ $invitation->setRecipientEmail($email);
+ $invitation->setRecipientName($name);
+ $invitation->setRecipientProvider($recipientProvider);
+ $invitation->setRecipientUserId($userID);
+ $invitation->setAcceptedAt($updated);
+ $invitation = $this->federatedInviteMapper->update($invitation);
+ return new JSONResponse($response, $status);
+ }
+}
diff --git a/lib/Service/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 ac9cbe6342..be9aee41d9 100644
--- a/lib/Service/SocialApiService.php
+++ b/lib/Service/SocialApiService.php
@@ -9,8 +9,11 @@
namespace OCA\Contacts\Service;
+use Exception;
use OCA\Contacts\AppInfo\Application;
+use OCA\Contacts\Exception\ContactExistsException;
use OCA\Contacts\Service\Social\CompositeSocialProvider;
+use OCA\DAV\CardDAV\CardDavBackend;
use OCA\DAV\CardDAV\ContactsManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
@@ -20,9 +23,11 @@
use OCP\Http\Client\IClientService;
use OCP\IAddressBook;
use OCP\IConfig;
+use OCP\ICreateContactFromString;
use OCP\IL10N;
use OCP\IURLGenerator;
use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
use function in_array;
class SocialApiService {
@@ -44,6 +49,7 @@ public function __construct(
private IURLGenerator $urlGen,
private ITimeFactory $timeFactory,
private ImageResizer $imageResizer,
+ private LoggerInterface $logger,
) {
$this->appName = Application::APP_ID;
}
@@ -94,30 +100,22 @@ protected function addPhoto(array &$contact, string $imageType, string $photo) {
/**
* Gets the addressbook of an addressbookId
*
- * @param string $addressBookId the identifier of the addressbook
+ * @param string $addressbookId the identifier of the addressbook
* @param IManager|null $manager optional a ContactManager to use
*
* @return IAddressBook|null the corresponding addressbook or null
*/
- protected function getAddressBook(string $addressBookId, ?IManager $manager = null) : ?IAddressBook {
+ protected function getAddressBook(string $addressbookId, ?IManager $manager = null) : ?IAddressBook {
$addressBook = null;
if ($manager === null) {
$manager = $this->manager;
}
$addressBooks = $manager->getUserAddressBooks();
foreach ($addressBooks as $ab) {
- if ($ab->getUri() === $addressBookId) {
+ if ($ab->getUri() === $addressbookId) {
$addressBook = $ab;
}
}
-
- $addressBookIsUpdatable = $addressBook !== null
- && ($addressBook->getPermissions() & Constants::PERMISSION_UPDATE);
-
- if (!$addressBookIsUpdatable) {
- return null;
- }
-
return $addressBook;
}
@@ -166,7 +164,11 @@ public function updateContact(string $addressbookId, string $contactId, ?string
$contact = $contacts[0];
if ($network) {
- $allConnectors = [$this->socialProvider->getSocialConnector($network)];
+ $connector = $this->socialProvider->getSocialConnector($network);
+ if ($connector === null) {
+ return new JSONResponse([], Http::STATUS_BAD_REQUEST);
+ }
+ $allConnectors = [$connector];
}
$connectors = array_filter($allConnectors, function ($connector) use ($contact) {
@@ -191,7 +193,10 @@ public function updateContact(string $addressbookId, string $contactId, ?string
try {
$httpResult = $this->clientService->newClient()->get($url);
$socialdata = $httpResult->getBody();
- $imageType = $httpResult->getHeader('content-type');
+ $imageTypeHeader = $httpResult->getHeader('content-type');
+ if (is_string($imageTypeHeader) && $imageTypeHeader !== '') {
+ $imageType = strtolower(trim(explode(';', $imageTypeHeader, 2)[0]));
+ }
if (isset($socialdata) && !empty($imageType)) {
break;
}
@@ -234,6 +239,130 @@ public function updateContact(string $addressbookId, string $contactId, ?string
return new JSONResponse([], Http::STATUS_OK);
}
+ /**
+ * Creates a contact and adds it to the address book of the local user with the specified userId,
+ * unless a contact with the specified cloudId already exists for that local user.
+ *
+ * @param {string} cloudId the cloud id of the contact
+ * @param {string} email the email of the contact
+ * @param {string} name the name of the contact
+ * @param {string} userId the uid of the local user
+ * @throws ContactExistsException
+ */
+ public function createContact(string $cloudId, string $email, string $name, string $userId): ?array {
+ try {
+ // Set up the contacts provider for the user with the specified uid
+ $cm = $this->serverContainer->get(ContactsManager::class);
+ $cm->setupContactsProvider($this->manager, $userId, $this->urlGen);
+
+ // if contact already exists we throw ContactExistsException
+ $searchResult = $this->manager->search($cloudId, ['CLOUD']);
+ if (count($searchResult) > 0) {
+ $this->logger->info('Contact with cloud id ' . $cloudId . ' already exists.', ['app' => Application::APP_ID]);
+ throw new ContactExistsException('Contact with cloud id ' . $cloudId . ' already exists.');
+ }
+
+ $addressBook = $this->pickAddressBookForContactCreation($this->manager->getUserAddressBooks());
+ if (!isset($addressBook)) {
+ $this->logger->error('No suitable address book found. Unable to add the new contact on invite accepted.', ['app' => Application::APP_ID]);
+ return null;
+ }
+
+ $newContact = $this->manager->createOrUpdate(
+ [
+ 'FN' => $name,
+ 'EMAIL' => $email,
+ 'CLOUD' => $cloudId,
+ ],
+ $addressBook->getKey()
+ );
+ $newContact['ADDRESSBOOK_URI'] = $addressBook->getUri();
+ return $newContact;
+ } catch (ContactExistsException $e) {
+ throw $e;
+ } catch (Exception $e) {
+ $this->logger->error('An exception occurred creating a new contact: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ }
+ return null;
+ }
+
+ /**
+ * Creates a federated contact (no thrown exceptions; null on duplicate or
+ * when no suitable writable address book exists).
+ *
+ * Used by the FederatedInviteAcceptedListener on the inviter side, where
+ * there is no user session and the inviter UID must be passed explicitly.
+ *
+ * @param string $cloudId the cloud id of the federated contact
+ * @param string $email the email of the federated contact
+ * @param string $name the display name of the federated contact
+ * @param string $userId the uid of the local (inviter) user
+ *
+ * @return array|null the created contact array, or null if a contact with
+ * that cloud id already exists or there is no suitable
+ * writable address book for the inviter
+ */
+ public function createFederatedContact(string $cloudId, string $email, string $name, string $userId): ?array {
+ try {
+ $cm = $this->serverContainer->get(ContactsManager::class);
+ $cm->setupContactsProvider($this->manager, $userId, $this->urlGen);
+
+ $searchResult = $this->manager->search($cloudId, ['CLOUD']);
+ if (count($searchResult) > 0) {
+ $this->logger->info('Contact with cloud id ' . $cloudId . ' already exists.', ['app' => Application::APP_ID]);
+ return null;
+ }
+
+ $addressBook = $this->pickAddressBookForContactCreation($this->manager->getUserAddressBooks());
+ if (!isset($addressBook)) {
+ $this->logger->error('No suitable address book found. Unable to add the new contact on invite accepted.', ['app' => Application::APP_ID]);
+ return null;
+ }
+
+ $newContact = $this->manager->createOrUpdate(
+ [
+ 'FN' => $name,
+ 'EMAIL' => $email,
+ 'CLOUD' => $cloudId,
+ ],
+ $addressBook->getKey()
+ );
+ return $newContact;
+ } catch (Exception $e) {
+ $this->logger->error('An exception occurred creating a federated contact: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ }
+ return null;
+ }
+
+ /**
+ * Pick a destination book using the same order as ImportController:
+ * personal address book first, then first writable non-shared.
+ */
+ private function pickAddressBookForContactCreation(array $addressBooks): ?IAddressBook {
+ $creatableAddressBooks = array_filter(
+ $addressBooks,
+ static fn (IAddressBook $addressBook): bool => $addressBook instanceof ICreateContactFromString,
+ );
+
+ foreach ($creatableAddressBooks as $addressBook) {
+ if ($addressBook->getUri() === CardDavBackend::PERSONAL_ADDRESSBOOK_URI) {
+ return $addressBook;
+ }
+ }
+
+ foreach ($creatableAddressBooks as $addressBook) {
+ if ($addressBook->isShared()) {
+ continue;
+ }
+ if (($addressBook->getPermissions() & Constants::PERMISSION_CREATE) === 0) {
+ continue;
+ }
+ return $addressBook;
+ }
+
+ return null;
+ }
+
/**
* checks an addressbook is existing
*
diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php
index efc2a15290..046901d8eb 100644
--- a/lib/Settings/AdminSettings.php
+++ b/lib/Settings/AdminSettings.php
@@ -8,6 +8,7 @@
namespace OCA\Contacts\Settings;
use OCA\Contacts\AppInfo\Application;
+use OCA\Contacts\Service\FederatedInvitesService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
@@ -16,28 +17,24 @@
class AdminSettings implements ISettings {
protected $appName;
- /**
- * Admin constructor.
- *
- * @param IConfig $config
- * @param IL10N $l
- */
public function __construct(
private IConfig $config,
private IInitialState $initialState,
+ private FederatedInvitesService $federatedInvitesService,
) {
$this->appName = Application::APP_ID;
}
- /**
- * @return TemplateResponse
- */
#[\Override]
- public function getForm() {
+ public function getForm(): TemplateResponse {
foreach (Application::AVAIL_SETTINGS as $key => $default) {
$data = $this->config->getAppValue($this->appName, $key, $default);
$this->initialState->provideInitialState($key, $data);
}
+ $this->initialState->provideInitialState(
+ 'ocmInvitesConfig',
+ $this->federatedInvitesService->getOcmInvitesConfig(),
+ );
return new TemplateResponse($this->appName, 'settings/admin');
}
diff --git a/lib/WayfProvider.php b/lib/WayfProvider.php
new file mode 100644
index 0000000000..da624760fc
--- /dev/null
+++ b/lib/WayfProvider.php
@@ -0,0 +1,151 @@
+appConfig->getValueString(Application::APP_ID, ConfigLexicon::MESH_PROVIDERS_SERVICE)));
+ $federations = [];
+ $ourServerUrlParts = parse_url($this->urlGenerator->getAbsoluteUrl('/'));
+ $ourFqdn = is_array($ourServerUrlParts) && isset($ourServerUrlParts['host']) ? (string)$ourServerUrlParts['host'] : '';
+
+ $found = [];
+ foreach ($urls as $url) {
+ if ($url === '') {
+ continue;
+ }
+ try {
+ $res = $this->httpClient->newClient()->get($url);
+ $code = $res->getStatusCode();
+ if (!($code >= Http::STATUS_OK && $code < Http::STATUS_BAD_REQUEST)) {
+ continue;
+ }
+ $data = json_decode($res->getBody(), true);
+ $fed = $data['federation'] ?? 'Unknown';
+ $federations[$fed] = $federations[$fed] ?? [];
+
+ $servers = is_array($data['servers'] ?? null) ? $data['servers'] : [];
+ foreach ($servers as $prov) {
+ $providerUrl = is_array($prov) && isset($prov['url']) ? (string)$prov['url'] : '';
+ if ($providerUrl === '') {
+ continue;
+ }
+ $fqdn = parse_url($providerUrl, PHP_URL_HOST);
+ if (!is_string($fqdn) || $fqdn === '') {
+ continue;
+ }
+ if (($ourFqdn !== '' && $ourFqdn === $fqdn) || in_array($fqdn, $found, true)) {
+ continue;
+ }
+ try {
+ $disc = $this->discovery->discover($providerUrl, true);
+ $inviteAcceptDialog = $disc->getInviteAcceptDialog();
+ } catch (Exception $e) {
+ $this->logger->error('Discovery failed for ' . $providerUrl . ': ' . $e->getMessage(), ['app' => Application::APP_ID]);
+ continue;
+ }
+ if ($inviteAcceptDialog === '') {
+ // We fall back on Nextcloud default path
+ $inviteAcceptDialogPath = self::getInviteAcceptDialogPath();
+ $inviteAcceptDialog = rtrim($providerUrl, '/') . $inviteAcceptDialogPath;
+ }
+ $federations[$fed][] = [
+ 'provider' => $disc->getProvider(),
+ 'name' => (string)($prov['displayName'] ?? $fqdn),
+ 'fqdn' => $fqdn,
+ 'inviteAcceptDialog' => $inviteAcceptDialog,
+ ];
+ array_push($found, $fqdn);
+ }
+ usort($federations[$fed], fn ($a, $b) => strcmp($a['name'], $b['name']));
+ } catch (Exception $e) {
+ $this->logger->error('Fetch failed for ' . $url . ': ' . $e->getMessage(), ['app' => Application::APP_ID]);
+ }
+ }
+ return $federations;
+ }
+
+ /**
+ * Returns all mesh providers from cache if possible.
+ *
+ * @return array an array containing all mesh providers
+ */
+ public function getMeshProvidersFromCache(): array {
+ $data = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, [], true);
+ $expires = is_array($data) && array_key_exists('expires', $data) ? (int)$data['expires'] : 0;
+ if (is_array($data) && $expires > time()) {
+ $this->logger->debug('Cache hit, expires at: ' . $expires, ['app' => Application::APP_ID]);
+ unset($data['expires']);
+ return $data;
+ }
+
+ $this->logger->debug('Cache miss or expired: cron job should update providers.', ['app' => Application::APP_ID]);
+ return $this->getMeshProviders();
+ }
+
+ /**
+ * Returns the WAYF (Where Are You From) login page endpoint to be used in the invitation link.
+ * Can be read from the app config key in ConfigLexicon::WAYF_ENDPOINT.
+ * If not set the endpoint the WAYF page implementation of this app is returned.
+ * Note that the invitation link still needs the token and provider parameters, eg. "https://?token=$token&provider=$provider"
+ *
+ * Security: the value of ConfigLexicon::WAYF_ENDPOINT is used as the base of every
+ * outgoing invitation URL. It is administrator-only configuration and
+ * must point to a trusted WAYF page that the recipient can safely visit.
+ * Setting it to an attacker-controlled origin would let invite links
+ * leak the token and provider query parameters to a third party.
+ *
+ * @return string|null the WAYF login page endpoint or null if it could not be created
+ */
+ public function getWayfEndpoint(): ?string {
+ // default wayf endpoint
+ $defaultWayfEndpoint = $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.federatedinvites.wayf');
+ $configuredEndpoint = trim($this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::WAYF_ENDPOINT));
+ return $configuredEndpoint === '' ? $defaultWayfEndpoint : $configuredEndpoint;
+ }
+
+ /**
+ * Returns the path of the invite accept dialog route.
+ *
+ * @return string
+ */
+ public function getInviteAcceptDialogPath(): string {
+ return $this->urlGenerator->linkToRoute(Application::APP_ID . '.federatedinvites.inviteacceptdialog');
+ }
+}
diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue
index 0d79be5c7f..ad6b88c377 100644
--- a/src/components/AdminSettings.vue
+++ b/src/components/AdminSettings.vue
@@ -12,28 +12,76 @@
v-model="allowSocialSync"
type="checkbox"
class="checkbox"
- @change="updateSetting('allowSocialSync')">
+ @change="updateSocialSetting('allowSocialSync')">
{{ t('contacts', 'Allow updating avatars from social media') }}
+
+ {{ t('contacts', 'OCM invites') }}
+
+
+ {{ t('contacts', 'Allow creating invites without an email address (link-only)') }}
+
+
+
+ {{ t('contacts', 'Offer the option to also send a copy of the invite to the sender') }}
+
+
+
+ {{ t('contacts', 'Show the "Copy encoded invite code" button on invite details') }}
+
+
+
diff --git a/src/components/AppNavigation/RootNavigation.vue b/src/components/AppNavigation/RootNavigation.vue
index de60ab6ca3..3bbc08604b 100644
--- a/src/components/AppNavigation/RootNavigation.vue
+++ b/src/components/AppNavigation/RootNavigation.vue
@@ -94,6 +94,26 @@
+
+
+
+
+
+
+
+
+
+
this.contacts[contact.key]?.groups && this.contacts[contact.key]?.groups?.length === 0)
@@ -422,7 +453,7 @@ export default {
: t('contacts', 'Collapse teams')
},
- ...mapStores(useUserGroupStore),
+ ...mapStores(useOcmInvitesStore, useUserGroupStore),
},
methods: {
diff --git a/src/components/Ocm/OcmAcceptForm.vue b/src/components/Ocm/OcmAcceptForm.vue
new file mode 100644
index 0000000000..2ba4646890
--- /dev/null
+++ b/src/components/Ocm/OcmAcceptForm.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Ocm/OcmAttachEmailForm.vue b/src/components/Ocm/OcmAttachEmailForm.vue
new file mode 100644
index 0000000000..21c981c4ad
--- /dev/null
+++ b/src/components/Ocm/OcmAttachEmailForm.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Ocm/OcmInviteAccept.vue b/src/components/Ocm/OcmInviteAccept.vue
new file mode 100644
index 0000000000..f00649e13e
--- /dev/null
+++ b/src/components/Ocm/OcmInviteAccept.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Ocm/OcmInviteDetails.vue b/src/components/Ocm/OcmInviteDetails.vue
new file mode 100644
index 0000000000..0628b48b1c
--- /dev/null
+++ b/src/components/Ocm/OcmInviteDetails.vue
@@ -0,0 +1,453 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('contacts', 'OCM invite') }}
+
+
+
+ {{ t('contacts', 'Label') }}
+ {{ invite.recipientName }}
+
+
+ {{ t('contacts', 'Sent to') }}
+ {{ invite.recipientEmail }}
+
+
+ {{ t('contacts', 'Created') }}
+ {{ formatDate(invite.createdAt) }}
+
+
+ {{ t('contacts', 'Expires') }}
+ {{ formatDate(invite.expiredAt) }}
+
+
+
+
+
+
+ {{ t('contacts', 'More ways to share') }}
+
+
+ {{ t('contacts', 'Useful for chat apps and manual acceptance. The recipient already received the invite by email.') }}
+
+
+
+
+
{{ t('contacts', 'Share invite') }}
+
+ {{ t('contacts', 'The invite link is the easiest way to share. Invite codes like token@provider are for manual acceptance.') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('contacts', 'Resend email') }}
+
+
+
+
+
+ {{ t('contacts', 'Send via email') }}
+
+
+ {{ t('contacts', 'Revoke invite') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Ocm/OcmInviteForm.vue b/src/components/Ocm/OcmInviteForm.vue
new file mode 100644
index 0000000000..3f3070b5d1
--- /dev/null
+++ b/src/components/Ocm/OcmInviteForm.vue
@@ -0,0 +1,256 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Ocm/OcmInviteShareActions.vue b/src/components/Ocm/OcmInviteShareActions.vue
new file mode 100644
index 0000000000..ef57d7306b
--- /dev/null
+++ b/src/components/Ocm/OcmInviteShareActions.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+ {{ t('contacts', 'Copy invite link') }}
+
+
+
+
+
+ {{ t('contacts', 'Copy invite code') }}
+
+
+
+
+
+ {{ t('contacts', 'Copy encoded invite code') }}
+
+
+
+
+
+
+
diff --git a/src/components/Ocm/OcmInvitesList.vue b/src/components/Ocm/OcmInvitesList.vue
new file mode 100644
index 0000000000..183a7f7813
--- /dev/null
+++ b/src/components/Ocm/OcmInvitesList.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Ocm/OcmInvitesListItem.vue b/src/components/Ocm/OcmInvitesListItem.vue
new file mode 100644
index 0000000000..a0e8d836bd
--- /dev/null
+++ b/src/components/Ocm/OcmInvitesListItem.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Ocm/Wayf.vue b/src/components/Ocm/Wayf.vue
new file mode 100644
index 0000000000..54f930f6d6
--- /dev/null
+++ b/src/components/Ocm/Wayf.vue
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
{{ t('contacts', 'Providers') }}
+
{{ t('contacts', 'Where are you from?') }}
+
{{ t('contacts', 'Please tell us your cloud provider.') }}
+
+
+
+
+
+
+
+
+
{{ group.federation }}
+
+
+
+
+ {{ t('contacts', 'No providers match your search.') }}
+
+
+
+ {{ t('contacts', 'No providers are currently available.') }}
+
+
+
+
+
{{ t('contacts', 'You need an invite code for this feature to work.') }}
+
+
+
+
+
+
+
diff --git a/src/css/wayf.scss b/src/css/wayf.scss
new file mode 100755
index 0000000000..280418b04a
--- /dev/null
+++ b/src/css/wayf.scss
@@ -0,0 +1,57 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#contacts-wayf {
+ background: var(--color-background-plain);
+ color: var(--color-background-plain-text);
+ border-radius: 8px;
+}
+
+.wayf-list {
+ text-align: start;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+}
+
+.wayf-list > li {
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+
+ padding: 0.75rem 1rem;
+ margin: 0.25rem 0;
+
+ background-color: var(--color-background-dark);
+ color: var(--color-main-text);
+
+ cursor: pointer;
+ text-decoration: none;
+ transition: background-color 0.15s;
+}
+
+.wayf-list > li:hover {
+ background-color: var(--color-background-darker);
+}
+
+.wayf-list > li:active {
+ background-color: var(--color-primary);
+ color: var(--color-primary-text);
+}
+
+.wayf-empty {
+ margin-block: 0.75rem 1rem;
+ color: var(--color-text-maxcontrast);
+}
+
+.wayf-manual-form {
+ margin-top: 1rem;
+}
+
+.wayf-manual-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 0.5rem;
+}
diff --git a/src/models/constants.ts b/src/models/constants.ts
index fed33ce1ad..ad3ce95d72 100644
--- a/src/models/constants.ts
+++ b/src/models/constants.ts
@@ -4,6 +4,7 @@
*/
///
+import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { ShareType } from '@nextcloud/sharing'
@@ -29,6 +30,19 @@ export const ROUTE_CIRCLE = 'circle'
export const ROUTE_CHART = 'chart'
export const ROUTE_USER_GROUP = 'user_group'
+const acceptInviteDialogUrl = loadState('contacts', 'acceptInviteDialogUrl', '/ocm/invite-accept-dialog')
+export const ROUTE_INVITE_ACCEPT_DIALOG = acceptInviteDialogUrl
+export const ROUTE_NAME_INVITE_ACCEPT_DIALOG = 'invite_accept_dialog'
+export const ROUTE_ALL_OCM_INVITES = 'ocm-invites'
+export const ROUTE_NAME_ALL_OCM_INVITES = 'all_ocm_invites'
+export const ROUTE_NAME_OCM_INVITE = 'ocm_invite'
+export const GROUP_ALL_OCM_INVITES = t('contacts', 'All invites')
+export const OCM_INVITES_CONFIG_KEYS = {
+ optionalMail: 'ocm_invites_optional_mail',
+ ccSender: 'ocm_invites_cc_sender',
+ encodedCopyButton: 'ocm_invites_encoded_copy_button',
+} as const
+
// Contact settings
export const CONTACTS_SETTINGS: DefaultGroup = t('contacts', 'Contacts settings')
diff --git a/src/models/ocminvite.ts b/src/models/ocminvite.ts
new file mode 100644
index 0000000000..57f2835058
--- /dev/null
+++ b/src/models/ocminvite.ts
@@ -0,0 +1,59 @@
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { t } from '@nextcloud/l10n'
+
+/**
+ * Raw invite payload as returned by the federated_invites endpoint.
+ */
+export interface OcmInviteData {
+ token: string
+ accepted?: boolean
+ recipientName?: string
+ recipientEmail?: string
+ recipientUserId?: string
+ recipientProvider?: string
+ createdAt?: number
+ expiredAt?: number
+ acceptedAt?: number
+}
+
+/**
+ * Store-side shape: same data as OcmInviteData plus a derived `key` used
+ * for keyed lookups and stable list rendering.
+ */
+export interface OcmInviteEntry extends OcmInviteData {
+ key: string
+}
+
+/**
+ * Build a store-friendly entry from a raw invite payload. Returns null
+ * when the payload is missing the token, since the token is the only
+ * field we can use as a stable key.
+ */
+export function toOcmInviteEntry(data: OcmInviteData | null | undefined): OcmInviteEntry | null {
+ if (!data || typeof data !== 'object' || !data.token) {
+ return null
+ }
+ return { ...data, key: data.token }
+}
+
+/**
+ * Label used in lists and headings. Falls back to the recipient email,
+ * then to a neutral "link-only invite" string when no email was given.
+ */
+export function getOcmInviteDisplayName(invite: OcmInviteData): string {
+ return invite.recipientName || invite.recipientEmail || t('contacts', 'Link-only invite')
+}
+
+/**
+ * Searchable text for an invite. Joins the recipient name and email
+ * when present, otherwise falls back to the same neutral label so
+ * link-only invites still match a search for that phrase.
+ */
+export function getOcmInviteSearchData(invite: OcmInviteData): string {
+ const parts = [invite.recipientName, invite.recipientEmail].filter(Boolean) as string[]
+ return parts.length > 0 ? parts.join(' ') : t('contacts', 'Link-only invite')
+}
diff --git a/src/router/index.js b/src/router/index.js
index 4913933020..9fd19ffb97 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -6,7 +6,7 @@
import { generateUrl } from '@nextcloud/router'
import { createRouter, createWebHistory } from 'vue-router'
import Contacts from '../views/Contacts.vue'
-import { ROUTE_CHART, ROUTE_CIRCLE, ROUTE_USER_GROUP } from '../models/constants.ts'
+import { GROUP_ALL_OCM_INVITES, ROUTE_ALL_OCM_INVITES, ROUTE_CHART, ROUTE_CIRCLE, ROUTE_INVITE_ACCEPT_DIALOG, ROUTE_NAME_ALL_OCM_INVITES, ROUTE_NAME_INVITE_ACCEPT_DIALOG, ROUTE_NAME_OCM_INVITE, ROUTE_USER_GROUP } from '../models/constants.ts'
// if index.php is in the url AND we got this far, then it's working:
// let's keep using index.php in the url
@@ -27,6 +27,23 @@ export default createRouter({
params: { selectedGroup: t('contacts', 'All contacts') },
},
children: [
+ {
+ path: `/${ROUTE_ALL_OCM_INVITES}`,
+ name: ROUTE_NAME_ALL_OCM_INVITES,
+ component: Contacts,
+ meta: { selectedGroup: GROUP_ALL_OCM_INVITES },
+ },
+ {
+ path: `/${ROUTE_ALL_OCM_INVITES}/:selectedInvite`,
+ name: ROUTE_NAME_OCM_INVITE,
+ component: Contacts,
+ meta: { selectedGroup: GROUP_ALL_OCM_INVITES },
+ },
+ {
+ path: ROUTE_INVITE_ACCEPT_DIALOG,
+ name: ROUTE_NAME_INVITE_ACCEPT_DIALOG,
+ component: Contacts,
+ },
{
path: `/${ROUTE_CHART}/:selectedChart`,
name: 'chart',
diff --git a/src/services/isOcmInvitesEnabled.js b/src/services/isOcmInvitesEnabled.js
new file mode 100644
index 0000000000..555d7479bf
--- /dev/null
+++ b/src/services/isOcmInvitesEnabled.js
@@ -0,0 +1,9 @@
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { loadState } from '@nextcloud/initial-state'
+
+const isOcmInvitesEnabled = loadState('contacts', 'isOcmInvitesEnabled', false)
+export default isOcmInvitesEnabled
diff --git a/src/store/ocminvites.ts b/src/store/ocminvites.ts
new file mode 100644
index 0000000000..481c066bbe
--- /dev/null
+++ b/src/store/ocminvites.ts
@@ -0,0 +1,214 @@
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { OcmInviteData, OcmInviteEntry } from '../models/ocminvite.ts'
+
+import axios from '@nextcloud/axios'
+import { generateUrl } from '@nextcloud/router'
+import { defineStore } from 'pinia'
+import { toOcmInviteEntry } from '../models/ocminvite.ts'
+import logger from '../services/logger.js'
+
+interface SortedEntry {
+ key: string
+ value: string | number | boolean | undefined
+}
+
+interface OcmInvitesState {
+ ocmInvites: Record
+ sortedOcmInvites: SortedEntry[]
+ orderKey: keyof OcmInviteData
+ inviteListStatus: 'idle' | 'loading' | 'success' | 'error'
+ inviteListError: string | null
+}
+
+interface NewInvitePayload {
+ email?: string
+ message?: string
+ note?: string
+ ccSender?: boolean
+}
+
+interface AttachEmailPayload {
+ token: string
+ email?: string
+ message?: string
+}
+
+function getSortValue(value: SortedEntry['value']): string {
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value)
+ }
+ if (typeof value === 'string') {
+ return value.toLowerCase()
+ }
+ return ''
+}
+
+function sortData(a: SortedEntry, b: SortedEntry): number {
+ const byValue = getSortValue(a.value).localeCompare(getSortValue(b.value), undefined, { numeric: true })
+ if (byValue !== 0) {
+ return byValue
+ }
+ return a.key.localeCompare(b.key)
+}
+
+const useOcmInvitesStore = defineStore('ocminvites', {
+ state: (): OcmInvitesState => ({
+ // Object-keyed map for O(1) lookups; the sortedOcmInvites array
+ // keeps a precomputed display order so list views do not pay the
+ // cost of resorting on every render.
+ // https://codepen.io/skjnldsv/pen/ZmKvQo
+ ocmInvites: {},
+ sortedOcmInvites: [],
+ orderKey: 'recipientEmail',
+ inviteListStatus: 'idle',
+ inviteListError: null,
+ }),
+
+ getters: {
+ getOcmInvite: (state) => (key: string): OcmInviteEntry | undefined => state.ocmInvites[key],
+ },
+
+ actions: {
+ async fetchOcmInvites(): Promise {
+ this.inviteListStatus = 'loading'
+ this.inviteListError = null
+ try {
+ const response = await axios.get(generateUrl('/apps/contacts/ocm/invitations'))
+ if (!Array.isArray(response.data)) {
+ throw new Error('Invalid invite list payload from server')
+ }
+ const invites = response.data
+ this.replaceInvites(invites)
+ this.sortInvites()
+ this.inviteListStatus = 'success'
+ } catch (error) {
+ this.inviteListStatus = 'error'
+ this.inviteListError = error instanceof Error ? error.message : String(error)
+ logger.error('Error fetching OCM invites: ' + error)
+ throw error
+ }
+ },
+
+ async deleteOcmInvite(invite: OcmInviteEntry): Promise {
+ const token = invite.token
+ const url = generateUrl('/apps/contacts/ocm/invitations/{token}', { token })
+ try {
+ await axios.delete(url)
+ this.removeOcmInvite(invite.key)
+ } catch (error) {
+ logger.error('Error deleting OCM invite with token ' + token)
+ throw error
+ }
+ },
+
+ async resendOcmInvite(invite: OcmInviteEntry) {
+ const token = invite.token
+ const url = generateUrl('/apps/contacts/ocm/invitations/{token}/resend', { token })
+ try {
+ return await axios.patch(url)
+ } catch (error) {
+ logger.error('Error resending OCM invite with token ' + token)
+ throw error
+ }
+ },
+
+ async newOcmInvite(invite: NewInvitePayload) {
+ const url = generateUrl('/apps/contacts/ocm/invitations')
+ const payload = {
+ email: invite.email || '',
+ message: invite.message || '',
+ note: invite.note || '',
+ ccSender: invite.ccSender || false,
+ }
+ let response
+ try {
+ response = await axios.post(url, payload)
+ } catch (error) {
+ logger.error('Error creating a new OCM invite for ' + invite.email)
+ throw error
+ }
+ try {
+ await this.fetchOcmInvites()
+ } catch (error) {
+ logger.error('Invite created but refresh failed for ' + invite.email)
+ }
+ return response
+ },
+
+ async attachEmailAndSendOcmInvite({ token, email, message }: AttachEmailPayload) {
+ const url = generateUrl('/apps/contacts/ocm/invitations/{token}/email', { token })
+ const payload = {
+ email: email || '',
+ message: message || '',
+ }
+ let response
+ try {
+ response = await axios.patch(url, payload)
+ } catch (error) {
+ logger.error('Error attaching email to OCM invite with token ' + token)
+ throw error
+ }
+ if (response?.data) {
+ this.updateOcmInvite(response.data)
+ }
+ return response
+ },
+
+ /**
+ * Stores a fresh batch of raw invite payloads from the API. Skips
+ * any entry without a token because we cannot key it.
+ */
+ replaceInvites(invites: OcmInviteData[] = []): void {
+ this.ocmInvites = invites.reduce>((list, raw) => {
+ const entry = toOcmInviteEntry(raw)
+ if (entry) {
+ list[entry.key] = entry
+ } else {
+ logger.error('Invalid invite object received from API', { raw })
+ }
+ return list
+ }, {})
+ },
+
+ /**
+ * Recomputes the sorted index from the current invite map.
+ * Filtering with computed properties was too slow on large
+ * lists; a precomputed index is cheap to read and only refreshed
+ * on writes.
+ */
+ sortInvites(): void {
+ const invites = Object.values(this.ocmInvites) as OcmInviteEntry[]
+ this.sortedOcmInvites = invites
+ .map((invite) => ({ key: invite.key, value: invite[this.orderKey] }))
+ .sort(sortData)
+ },
+
+ removeOcmInvite(key: string): void {
+ const index = this.sortedOcmInvites.findIndex((entry) => entry.key === key)
+ if (index !== -1) {
+ this.sortedOcmInvites.splice(index, 1)
+ }
+ delete this.ocmInvites[key]
+ },
+
+ /**
+ * Replaces a single cached invite with a fresh server payload,
+ * keyed by token.
+ */
+ updateOcmInvite(raw: OcmInviteData): void {
+ const entry = toOcmInviteEntry(raw)
+ if (!entry) {
+ logger.error('Invalid invite object received from API', { raw })
+ return
+ }
+ this.ocmInvites = { ...this.ocmInvites, [entry.key]: entry }
+ this.sortInvites()
+ },
+ },
+})
+
+export default useOcmInvitesStore
diff --git a/src/views/Contacts.vue b/src/views/Contacts.vue
index befd9f7dc4..e2875465a6 100644
--- a/src/views/Contacts.vue
+++ b/src/views/Contacts.vue
@@ -8,7 +8,7 @@
@@ -25,6 +25,30 @@
{{ isCirclesView ? t('contacts', 'Add member') : t('contacts', 'New contact') }}
+
+
+
+
+
+ {{ t('contacts', 'Invite contact') }}
+
+
+
+
+
+
+ {{ t('contacts', 'Accept invite') }}
+
@@ -38,6 +62,12 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ newInvitePrimaryLabel }}
+
+
+
+
+
+
+ {{ t("contacts", "Cancel") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("contacts", "Accept") }}
+
+
+
+
+
+
+ {{ t("contacts", "Cancel") }}
+
+
+
+
+
+
@@ -60,53 +162,86 @@
diff --git a/src/wayf.js b/src/wayf.js
new file mode 100644
index 0000000000..f076a4069a
--- /dev/null
+++ b/src/wayf.js
@@ -0,0 +1,29 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { loadState } from '@nextcloud/initial-state'
+import { translatePlural as n, translate as t } from '@nextcloud/l10n'
+import { createApp } from 'vue'
+import Wayf from './components/Ocm/Wayf.vue'
+
+import './css/wayf.scss'
+
+function mountWayf() {
+ const props = loadState('contacts', 'wayf')
+ const app = createApp(Wayf, props)
+ app.config.globalProperties.t = t
+ app.config.globalProperties.n = n
+ app.mount('#contacts-wayf')
+}
+
+if (!document.body.id) {
+ document.body.id = 'body-public'
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', mountWayf)
+} else {
+ mountWayf()
+}
diff --git a/templates/wayf.php b/templates/wayf.php
new file mode 100644
index 0000000000..12a2ef561a
--- /dev/null
+++ b/templates/wayf.php
@@ -0,0 +1,7 @@
+
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index ee99d2c693..37d7d28341 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -14,4 +14,5 @@
require_once __DIR__ . '/../../../tests/autoload.php';
require_once __DIR__ . '/../vendor/autoload.php';
+Server::get(IAppManager::class)->loadApp('cloud_federation_api');
Server::get(IAppManager::class)->loadApp('contacts');
diff --git a/tests/javascript/components/ocm-accept-form.test.js b/tests/javascript/components/ocm-accept-form.test.js
new file mode 100644
index 0000000000..491c3d9f9f
--- /dev/null
+++ b/tests/javascript/components/ocm-accept-form.test.js
@@ -0,0 +1,45 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+jest.mock('@nextcloud/vue/components/NcButton', () => ({ default: {} }), { virtual: true })
+jest.mock('@nextcloud/vue/components/NcLoadingIcon', () => ({ default: {} }), { virtual: true })
+jest.mock('@nextcloud/vue/components/NcTextField', () => ({ default: {} }), { virtual: true })
+jest.mock('vue-material-design-icons/Cancel.vue', () => ({ default: {} }), { virtual: true })
+jest.mock('vue-material-design-icons/Check.vue', () => ({ default: {} }), { virtual: true })
+
+import OcmAcceptForm from '../../../src/components/Ocm/OcmAcceptForm.vue'
+
+const component = OcmAcceptForm.default || OcmAcceptForm
+
+describe('OcmAcceptForm invite parser', () => {
+ test('parses token@provider format', () => {
+ const parsed = component.methods.parseInvite('token123@provider.example')
+ expect(parsed).toEqual({
+ token: 'token123',
+ provider: 'provider.example',
+ })
+ })
+
+ test('parses absolute invite URL format', () => {
+ const parsed = component.methods.parseInvite('https://cloud.example/ocm/invite-accept-dialog?token=abc123&providerDomain=provider.example')
+ expect(parsed).toEqual({
+ token: 'abc123',
+ provider: 'provider.example',
+ })
+ })
+
+ test('parses encoded invite format', () => {
+ const encoded = Buffer.from('token123@provider.example', 'utf8').toString('base64')
+ const parsed = component.methods.parseInvite(encoded)
+ expect(parsed).toEqual({
+ token: 'token123',
+ provider: 'provider.example',
+ })
+ })
+
+ test('throws on invalid invite input', () => {
+ expect(() => component.methods.parseInvite('not-an-invite')).toThrow('Could not parse invite')
+ })
+})
diff --git a/tests/javascript/store/ocminvites.test.js b/tests/javascript/store/ocminvites.test.js
new file mode 100644
index 0000000000..eb97c2d45c
--- /dev/null
+++ b/tests/javascript/store/ocminvites.test.js
@@ -0,0 +1,330 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+jest.mock('@nextcloud/axios', () => ({
+ __esModule: true,
+ default: {
+ get: jest.fn(),
+ post: jest.fn(),
+ patch: jest.fn(),
+ delete: jest.fn(),
+ },
+}))
+
+jest.mock('@nextcloud/router', () => ({
+ __esModule: true,
+ generateUrl: (path, params = {}) => {
+ let result = path
+ for (const [key, value] of Object.entries(params)) {
+ result = result.replaceAll(`{${key}}`, encodeURIComponent(String(value)))
+ }
+ return result
+ },
+}))
+
+import axios from '@nextcloud/axios'
+import { createPinia, setActivePinia } from 'pinia'
+
+import { toOcmInviteEntry } from '../../../src/models/ocminvite.ts'
+import useOcmInvitesStore from '../../../src/store/ocminvites.ts'
+
+const TOKEN = 'token-1234'
+
+const flatInvitePayload = (overrides = {}) => ({
+ accepted: false,
+ acceptedAt: null,
+ createdAt: 1_800_000_000,
+ expiredAt: 1_800_000_000 + 2_592_000,
+ recipientEmail: 'recipient@example.org',
+ recipientName: null,
+ recipientProvider: null,
+ recipientUserId: null,
+ token: TOKEN,
+ userId: 'alice',
+ ...overrides,
+})
+
+describe('ocminvites store', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ setActivePinia(createPinia())
+ })
+
+ describe('attachEmailAndSendOcmInvite', () => {
+ test('PATCHes the per-invite email endpoint with the email and message payload', async () => {
+ axios.patch.mockResolvedValue({ data: flatInvitePayload() })
+
+ const store = useOcmInvitesStore()
+ await store.attachEmailAndSendOcmInvite({
+ token: TOKEN,
+ email: 'recipient@example.org',
+ message: 'hello',
+ })
+
+ expect(axios.patch).toHaveBeenCalledTimes(1)
+ const [url, payload] = axios.patch.mock.calls[0]
+ expect(url).toBe(`/apps/contacts/ocm/invitations/${TOKEN}/email`)
+ expect(payload).toEqual({
+ email: 'recipient@example.org',
+ message: 'hello',
+ })
+ })
+
+ test('coerces missing email and message to empty strings', async () => {
+ axios.patch.mockResolvedValue({ data: flatInvitePayload() })
+
+ const store = useOcmInvitesStore()
+ await store.attachEmailAndSendOcmInvite({ token: TOKEN })
+
+ const [, payload] = axios.patch.mock.calls[0]
+ expect(payload).toEqual({ email: '', message: '' })
+ })
+
+ test('stores a fresh invite entry from a flat backend response', async () => {
+ axios.patch.mockResolvedValue({ data: flatInvitePayload() })
+
+ const store = useOcmInvitesStore()
+ const response = await store.attachEmailAndSendOcmInvite({
+ token: TOKEN,
+ email: 'recipient@example.org',
+ message: '',
+ })
+
+ expect(response.data.token).toBe(TOKEN)
+ const stored = store.ocmInvites[TOKEN]
+ expect(stored.key).toBe(TOKEN)
+ expect(stored.token).toBe(TOKEN)
+ expect(stored.recipientEmail).toBe('recipient@example.org')
+ expect(store.sortedOcmInvites).toHaveLength(1)
+ expect(store.sortedOcmInvites[0].key).toBe(TOKEN)
+ })
+
+ test('rethrows when the request fails and leaves state untouched', async () => {
+ const failure = new Error('boom')
+ axios.patch.mockRejectedValue(failure)
+
+ const store = useOcmInvitesStore()
+ await expect(
+ store.attachEmailAndSendOcmInvite({
+ token: TOKEN,
+ email: 'recipient@example.org',
+ message: '',
+ }),
+ ).rejects.toBe(failure)
+
+ expect(store.ocmInvites).toEqual({})
+ expect(store.sortedOcmInvites).toEqual([])
+ })
+ })
+
+ describe('fetchOcmInvites', () => {
+ test('replaces existing invite map with latest server payload', async () => {
+ axios.get.mockResolvedValue({
+ data: [flatInvitePayload({ token: 'fresh-token', recipientEmail: 'fresh@example.org' })],
+ })
+
+ const store = useOcmInvitesStore()
+ store.ocmInvites = {
+ 'stale-token': toOcmInviteEntry(flatInvitePayload({ token: 'stale-token', recipientEmail: 'stale@example.org' })),
+ }
+
+ await store.fetchOcmInvites()
+
+ expect(Object.keys(store.ocmInvites)).toEqual(['fresh-token'])
+ expect(store.ocmInvites['fresh-token'].recipientEmail).toBe('fresh@example.org')
+ expect(store.inviteListStatus).toBe('success')
+ expect(store.inviteListError).toBeNull()
+ })
+
+ test('sorts by recipientEmail value, not token key', async () => {
+ axios.get.mockResolvedValue({
+ data: [
+ flatInvitePayload({ token: 'z-token', recipientEmail: 'zeta@example.org' }),
+ flatInvitePayload({ token: 'a-token', recipientEmail: 'alpha@example.org' }),
+ ],
+ })
+
+ const store = useOcmInvitesStore()
+ await store.fetchOcmInvites()
+
+ expect(store.sortedOcmInvites.map(entry => entry.key)).toEqual(['a-token', 'z-token'])
+ expect(store.inviteListStatus).toBe('success')
+ expect(store.inviteListError).toBeNull()
+ })
+
+ test('rethrows and sets error state when request fails', async () => {
+ const failure = new Error('network down')
+ axios.get.mockRejectedValue(failure)
+
+ const store = useOcmInvitesStore()
+ await expect(store.fetchOcmInvites()).rejects.toBe(failure)
+
+ expect(store.inviteListStatus).toBe('error')
+ expect(store.inviteListError).toContain('network down')
+ })
+
+ test('rejects malformed payloads and sets error state', async () => {
+ axios.get.mockResolvedValue({ data: { invalid: true } })
+
+ const store = useOcmInvitesStore()
+ await expect(store.fetchOcmInvites()).rejects.toThrow('Invalid invite list payload from server')
+
+ expect(store.inviteListStatus).toBe('error')
+ expect(store.inviteListError).toBe('Invalid invite list payload from server')
+ })
+ })
+
+ describe('updateOcmInvite action', () => {
+ test('replaces the invite for the matching token without dropping others', () => {
+ const store = useOcmInvitesStore()
+ store.ocmInvites = {
+ 'other-token': toOcmInviteEntry({ token: 'other-token', recipientEmail: 'other@example.org' }),
+ }
+
+ store.updateOcmInvite(flatInvitePayload({ recipientEmail: 'fresh@example.org' }))
+
+ expect(Object.keys(store.ocmInvites)).toEqual(expect.arrayContaining(['other-token', TOKEN]))
+ expect(store.ocmInvites[TOKEN].recipientEmail).toBe('fresh@example.org')
+ expect(store.ocmInvites['other-token'].recipientEmail).toBe('other@example.org')
+ })
+
+ test('ignores payloads without a token and never mutates state', () => {
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
+ const store = useOcmInvitesStore()
+
+ store.updateOcmInvite({ recipientEmail: 'no-token@example.org' })
+
+ expect(store.ocmInvites).toEqual({})
+ expect(errorSpy).toHaveBeenCalled()
+ errorSpy.mockRestore()
+ })
+ })
+
+ describe('removeOcmInvite action', () => {
+ test('removes only the targeted invite from the sorted list', () => {
+ const a = toOcmInviteEntry({ token: 'a' })
+ const b = toOcmInviteEntry({ token: 'b' })
+ const store = useOcmInvitesStore()
+ store.ocmInvites = { a, b }
+ store.sortedOcmInvites = [a, b]
+
+ store.removeOcmInvite('a')
+
+ expect(store.sortedOcmInvites.map(i => i.key)).toEqual(['b'])
+ expect(store.ocmInvites).not.toHaveProperty('a')
+ expect(store.ocmInvites).toHaveProperty('b')
+ })
+
+ test('does not splice the last entry when the key is unknown', () => {
+ const a = toOcmInviteEntry({ token: 'a' })
+ const b = toOcmInviteEntry({ token: 'b' })
+ const store = useOcmInvitesStore()
+ store.ocmInvites = { a, b }
+ store.sortedOcmInvites = [a, b]
+
+ store.removeOcmInvite('missing-key')
+
+ expect(store.sortedOcmInvites.map(i => i.key)).toEqual(['a', 'b'])
+ expect(store.ocmInvites).toEqual({ a, b })
+ })
+ })
+
+ describe('deleteOcmInvite', () => {
+ test('throws when revoke request fails', async () => {
+ const failure = new Error('delete failed')
+ axios.delete.mockRejectedValue(failure)
+
+ const store = useOcmInvitesStore()
+ const invite = toOcmInviteEntry(flatInvitePayload())
+ await expect(store.deleteOcmInvite(invite)).rejects.toBe(failure)
+ })
+ })
+
+ describe('create/resend behavior', () => {
+ test('newOcmInvite refreshes invite list after create', async () => {
+ axios.post.mockResolvedValue({ data: { invite: '/invite/link' } })
+ axios.get.mockResolvedValue({ data: [flatInvitePayload({ token: 'new-token' })] })
+
+ const store = useOcmInvitesStore()
+ await store.newOcmInvite({
+ email: 'recipient@example.org',
+ message: 'See you soon',
+ note: 'CERN contact',
+ ccSender: true,
+ })
+
+ expect(axios.post).toHaveBeenCalledTimes(1)
+ expect(axios.post).toHaveBeenCalledWith('/apps/contacts/ocm/invitations', {
+ email: 'recipient@example.org',
+ message: 'See you soon',
+ note: 'CERN contact',
+ ccSender: true,
+ })
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ expect(store.ocmInvites['new-token']).toBeDefined()
+ })
+
+ test('newOcmInvite defaults optional create payload fields', async () => {
+ axios.post.mockResolvedValue({ data: { invite: '/invite/link' } })
+ axios.get.mockResolvedValue({ data: [] })
+
+ const store = useOcmInvitesStore()
+ await store.newOcmInvite({})
+
+ expect(axios.post).toHaveBeenCalledWith('/apps/contacts/ocm/invitations', {
+ email: '',
+ message: '',
+ note: '',
+ ccSender: false,
+ })
+ })
+
+ test('resendOcmInvite returns resend response without reloading invites', async () => {
+ const resendResponse = { data: { invite: '/invite/link' } }
+ axios.patch.mockResolvedValue(resendResponse)
+
+ const store = useOcmInvitesStore()
+ const invite = toOcmInviteEntry(flatInvitePayload())
+ await expect(store.resendOcmInvite(invite)).resolves.toBe(resendResponse)
+
+ expect(axios.patch).toHaveBeenCalledTimes(1)
+ expect(axios.get).not.toHaveBeenCalled()
+ })
+
+ test('newOcmInvite resolves when refresh fetch fails', async () => {
+ const failure = new Error('refresh failed')
+ const createResponse = { data: { invite: '/invite/link' } }
+ axios.post.mockResolvedValue(createResponse)
+ axios.get.mockRejectedValue(failure)
+
+ const store = useOcmInvitesStore()
+ await expect(store.newOcmInvite({ email: 'recipient@example.org', message: '', note: '' })).resolves.toBe(createResponse)
+
+ expect(store.inviteListStatus).toBe('error')
+ expect(store.inviteListError).toContain('refresh failed')
+ })
+
+ test('newOcmInvite rejects when create request fails', async () => {
+ const failure = new Error('create failed')
+ axios.post.mockRejectedValue(failure)
+
+ const store = useOcmInvitesStore()
+ await expect(store.newOcmInvite({ email: 'recipient@example.org', message: '', note: '' })).rejects.toBe(failure)
+
+ expect(axios.get).not.toHaveBeenCalled()
+ })
+
+ test('resendOcmInvite rejects when resend request fails', async () => {
+ const failure = new Error('resend failed')
+ axios.patch.mockRejectedValue(failure)
+
+ const store = useOcmInvitesStore()
+ const invite = toOcmInviteEntry(flatInvitePayload())
+ await expect(store.resendOcmInvite(invite)).rejects.toBe(failure)
+
+ expect(axios.get).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/tests/javascript/views/contacts-ocm-flows.test.js b/tests/javascript/views/contacts-ocm-flows.test.js
new file mode 100644
index 0000000000..34b89fe1f9
--- /dev/null
+++ b/tests/javascript/views/contacts-ocm-flows.test.js
@@ -0,0 +1,202 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+jest.mock('@nextcloud/auth', () => ({
+ getCurrentUser: jest.fn(() => ({ uid: 'alice' })),
+}))
+
+jest.mock('@nextcloud/axios', () => ({
+ __esModule: true,
+ default: {
+ patch: jest.fn(),
+ },
+}))
+
+jest.mock('@nextcloud/dialogs', () => ({
+ showError: jest.fn(),
+}))
+
+jest.mock('@nextcloud/event-bus', () => ({
+ emit: jest.fn(),
+}))
+
+jest.mock('@nextcloud/initial-state', () => ({
+ loadState: jest.fn((app, key, fallback) => fallback),
+}))
+
+jest.mock('@nextcloud/router', () => ({
+ generateUrl: (path, params = {}) => {
+ let result = path
+ for (const [key, value] of Object.entries(params)) {
+ result = result.replace(`{${key}}`, String(value))
+ }
+ return result
+ },
+}))
+
+jest.mock('@nextcloud/vue', () => ({
+ NcButton: {},
+ NcContent: {},
+ NcLoadingIcon: {},
+ NcModal: {},
+}))
+
+jest.mock('ical.js', () => ({}))
+
+jest.mock('pinia', () => ({
+ mapStores: () => ({}),
+}))
+
+jest.mock('../../../src/components/AppContent/ChartContent.vue', () => ({ default: {} }))
+jest.mock('../../../src/components/AppContent/CircleContent.vue', () => ({ default: {} }))
+jest.mock('../../../src/components/AppContent/ContactsContent.vue', () => ({ default: {} }))
+jest.mock('../../../src/components/AppContent/OcmInvitesContent.vue', () => ({ default: {} }))
+jest.mock('../../../src/components/AppNavigation/RootNavigation.vue', () => ({ default: {} }))
+jest.mock('../../../src/components/AppNavigation/Settings/SettingsImportContacts.vue', () => ({ default: {} }))
+jest.mock('../../../src/components/EntityPicker/ContactsPicker.vue', () => ({ default: {} }))
+jest.mock('../../../src/components/Ocm/OcmAcceptForm.vue', () => ({ default: {} }))
+jest.mock('../../../src/components/Ocm/OcmInviteAccept.vue', () => ({ default: {} }))
+jest.mock('../../../src/components/Ocm/OcmInviteForm.vue', () => ({ default: {} }))
+jest.mock('../../../src/views/Processing/ImportView.vue', () => ({ default: {} }))
+jest.mock('../../../src/mixins/IsMobileMixin.ts', () => ({ default: {} }))
+jest.mock('../../../src/mixins/RouterMixin.js', () => ({ default: {} }))
+jest.mock('../../../src/models/constants.ts', () => ({
+ GROUP_ALL_CONTACTS: 'all',
+ GROUP_ALL_OCM_INVITES: 'all-ocm',
+ GROUP_NO_GROUP_CONTACTS: 'nogroup',
+ ROUTE_CIRCLE: 'circle',
+ ROUTE_NAME_ALL_OCM_INVITES: 'all_ocm_invites',
+ ROUTE_NAME_INVITE_ACCEPT_DIALOG: 'invite_accept_dialog',
+ ROUTE_NAME_OCM_INVITE: 'ocm_invite',
+ ROUTE_USER_GROUP: 'user-group',
+}))
+jest.mock('../../../src/models/contact.js', () => ({ default: class Contact {} }))
+jest.mock('../../../src/models/rfcProps.js', () => ({ default: {} }))
+jest.mock('../../../src/services/cdav.js', () => ({ default: {} }))
+jest.mock('../../../src/services/isCirclesEnabled.js', () => ({ default: false }))
+jest.mock('../../../src/services/isOcmInvitesEnabled.js', () => ({ default: true }))
+jest.mock('../../../src/services/logger.js', () => ({
+ __esModule: true,
+ default: {
+ error: jest.fn(),
+ },
+}))
+jest.mock('../../../src/store/ocminvites.ts', () => ({ default: jest.fn() }))
+jest.mock('../../../src/store/principals.js', () => ({ default: jest.fn() }))
+jest.mock('../../../src/store/userGroup.ts', () => ({ default: jest.fn() }))
+jest.mock('vue-material-design-icons/AccountArrowDownOutline.vue', () => ({ default: {} }))
+jest.mock('vue-material-design-icons/AccountSwitchOutline.vue', () => ({ default: {} }))
+jest.mock('vue-material-design-icons/Cancel.vue', () => ({ default: {} }))
+jest.mock('vue-material-design-icons/Check.vue', () => ({ default: {} }))
+jest.mock('vue-material-design-icons/Plus.vue', () => ({ default: {} }))
+
+import axios from '@nextcloud/axios'
+import { showError } from '@nextcloud/dialogs'
+import Contacts from '../../../src/views/Contacts.vue'
+
+const view = Contacts.default || Contacts
+
+describe('Contacts OCM flow methods', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('sendNewInvite keeps draft modal open on create failure', async () => {
+ const createFailure = { response: { data: { message: 'Could not create invite' } } }
+ const vm = {
+ loadingUpdate: false,
+ showNewInviteForm: true,
+ ocmInvite: { email: 'recipient@example.org', message: '', note: '', sendEmail: false },
+ ocmInvitesConfig: { optionalMail: true },
+ ocminvitesStore: {
+ newOcmInvite: jest.fn().mockRejectedValue(createFailure),
+ },
+ cancelNewInvite: jest.fn(),
+ t: (app, text) => text,
+ }
+
+ await view.methods.sendNewInvite.call(vm)
+
+ expect(vm.cancelNewInvite).not.toHaveBeenCalled()
+ expect(vm.showNewInviteForm).toBe(true)
+ expect(showError).toHaveBeenCalledWith('Could not create invite')
+ expect(vm.loadingUpdate).toBe(false)
+ })
+
+ test('sendNewInvite reports the short missing email message', async () => {
+ const vm = {
+ loadingUpdate: false,
+ ocmInvite: { email: '', message: '', note: '', sendEmail: true },
+ ocmInvitesConfig: { optionalMail: true },
+ ocminvitesStore: {
+ newOcmInvite: jest.fn(),
+ },
+ t: (app, text) => text,
+ }
+
+ await view.methods.sendNewInvite.call(vm)
+
+ expect(showError).toHaveBeenCalledWith('Please enter an email address.')
+ expect(vm.ocminvitesStore.newOcmInvite).not.toHaveBeenCalled()
+ expect(vm.loadingUpdate).toBe(false)
+ })
+
+ test('sendNewInvite submits link-only invites with an empty email', async () => {
+ const createFailure = { response: { data: { message: 'backend reached' } } }
+ const vm = {
+ loadingUpdate: false,
+ ocmInvite: { email: '', message: 'hello', note: 'mesh peer', sendEmail: false },
+ showNewInviteForm: true,
+ ocminvitesStore: {
+ newOcmInvite: jest.fn().mockRejectedValue(createFailure),
+ },
+ cancelNewInvite: jest.fn(),
+ t: (app, text) => text,
+ }
+
+ await view.methods.sendNewInvite.call(vm)
+
+ expect(vm.ocminvitesStore.newOcmInvite).toHaveBeenCalledWith(vm.ocmInvite)
+ expect(vm.cancelNewInvite).not.toHaveBeenCalled()
+ expect(showError).toHaveBeenCalledWith('backend reached')
+ expect(showError).not.toHaveBeenCalledWith('Please enter an email address.')
+ expect(vm.loadingUpdate).toBe(false)
+ })
+
+ test('handleAccept keeps manual modal open on failure', async () => {
+ axios.patch.mockRejectedValueOnce({ response: { data: { message: 'manual accept failed' } } })
+ const vm = {
+ loadingUpdate: false,
+ showManualInvite: true,
+ t: (app, text) => text,
+ }
+
+ await view.methods.handleAccept.call(vm, {
+ provider: 'provider.example',
+ token: 'invite-token',
+ })
+
+ expect(vm.showManualInvite).toBe(true)
+ expect(showError).toHaveBeenCalledWith('manual accept failed')
+ expect(vm.loadingUpdate).toBe(false)
+ })
+
+ test('acceptInvite keeps deep-link dialog open on failure', async () => {
+ axios.patch.mockRejectedValueOnce({ response: { data: { message: 'deep-link failed' } } })
+ const vm = {
+ loadingUpdate: false,
+ showInviteAcceptDialog: true,
+ inviteToken: 'invite-token',
+ inviteProvider: 'provider.example',
+ t: (app, text) => text,
+ }
+
+ await view.methods.acceptInvite.call(vm)
+
+ expect(vm.showInviteAcceptDialog).toBe(true)
+ expect(showError).toHaveBeenCalledWith('deep-link failed')
+ expect(vm.loadingUpdate).toBe(false)
+ })
+})
diff --git a/tests/unit/Controller/FederatedInvitesControllerTest.php b/tests/unit/Controller/FederatedInvitesControllerTest.php
new file mode 100644
index 0000000000..10a154e09c
--- /dev/null
+++ b/tests/unit/Controller/FederatedInvitesControllerTest.php
@@ -0,0 +1,831 @@
+request = $this->createMock(IRequest::class);
+ $this->addressHandler = $this->createMock(AddressHandler::class);
+ $this->defaults = $this->createMock(Defaults::class);
+ $this->mapper = $this->createMock(FederatedInviteMapper::class);
+ $this->invitesService = $this->createMock(FederatedInvitesService::class);
+ $this->appManager = $this->createMock(IAppManager::class);
+ $this->httpClient = $this->createMock(IClientService::class);
+ $this->config = $this->createMock(IConfig::class);
+ $this->initialState = $this->createMock(IInitialState::class);
+ $this->languageFactory = $this->createMock(IFactory::class);
+ $this->contactsManager = $this->createMock(IManager::class);
+ $this->mailer = $this->createMock(IMailer::class);
+ $this->discovery = $this->createMock(TestIOCMDiscoveryService::class);
+ $this->userSession = $this->createMock(IUserSession::class);
+ $this->wayfProvider = $this->createMock(WayfProvider::class);
+ $this->socialApi = $this->createMock(SocialApiService::class);
+ $this->timeFactory = $this->createMock(ITimeFactory::class);
+ $this->compareVersion = $this->createMock(CompareVersion::class);
+ $this->groupSharingService = $this->createMock(GroupSharingService::class);
+ $this->l10n = $this->createMock(IL10N::class);
+ $this->urlGenerator = $this->createMock(IURLGenerator::class);
+ $this->userManager = $this->createMock(IUserManager::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+
+ $this->l10n->method('t')->willReturnCallback(static fn (string $text, array $params = []): string => vsprintf($text, $params));
+ $this->invitesService->method('isOcmInvitesEnabled')->willReturn(true);
+ $this->mapper->method('deleteSupersededInvitesForRecipientEmail')->willReturn(0);
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn(self::UID);
+ $user->method('getDisplayName')->willReturn('Alice');
+ $user->method('getEMailAddress')->willReturn('alice@example.org');
+ $this->userSession->method('getUser')->willReturn($user);
+
+ $this->controller = new FederatedInvitesController(
+ $this->request,
+ $this->addressHandler,
+ $this->defaults,
+ $this->mapper,
+ $this->invitesService,
+ $this->appManager,
+ $this->httpClient,
+ $this->config,
+ $this->initialState,
+ $this->languageFactory,
+ $this->contactsManager,
+ $this->mailer,
+ $this->discovery,
+ $this->userSession,
+ $this->wayfProvider,
+ $this->socialApi,
+ $this->timeFactory,
+ $this->compareVersion,
+ $this->groupSharingService,
+ $this->l10n,
+ $this->urlGenerator,
+ $this->userManager,
+ $this->logger,
+ );
+ }
+
+ private function makeInvite(?string $email = null, bool $accepted = false, string $uid = self::UID): FederatedInvite {
+ $invite = new FederatedInvite();
+ $invite->setUserId($uid);
+ $invite->setToken(self::TOKEN);
+ $invite->setRecipientEmail($email);
+ $invite->setAccepted($accepted);
+ $invite->setCreatedAt(1_700_000_000);
+ $invite->setExpiredAt(1_700_000_000 + 2_592_000);
+ return $invite;
+ }
+
+ public function testGetInvitesReturnsStructuredErrorWhenMapperFails(): void {
+ $this->mapper->expects($this->once())
+ ->method('findOpenInvitesByUid')
+ ->with(self::UID)
+ ->willThrowException(new \RuntimeException('db fail'));
+
+ $response = $this->controller->getInvites();
+
+ $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus());
+ $this->assertSame('ocm_invites_fetch_failed', $response->getData()['code']);
+ }
+
+ public function testAttachEmailAndSendUpdatesAndSends(): void {
+ $invite = $this->makeInvite(null);
+
+ $this->mapper->expects($this->once())
+ ->method('findInviteByTokenAndUid')
+ ->with(self::TOKEN, self::UID)
+ ->willReturn($invite);
+ $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]);
+ $this->mailer->method('validateMailAddress')->willReturn(true);
+ $this->mailer->method('createMessage')->willReturn($this->createMock(\OCP\Mail\IMessage::class));
+ $this->mailer->method('send')->willReturn([]);
+ $this->wayfProvider->method('getWayfEndpoint')->willReturn('https://example.org/wayf');
+ $this->invitesService->method('getProviderFQDN')->willReturn('example.org');
+ $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000);
+ $now = $this->createMock(\DateTimeImmutable::class);
+ $now->method('getTimestamp')->willReturn(1_800_000_000);
+ $this->timeFactory->method('now')->willReturn($now);
+
+ $this->mapper->expects($this->once())
+ ->method('claimInviteForEmail')
+ ->with(self::TOKEN, self::UID, 'recipient@example.org', 1_800_000_000, 1_800_000_000 + 2_592_000)
+ ->willReturn(true);
+ $this->mapper->expects($this->never())->method('revertInviteEmail');
+
+ $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org', 'hello');
+
+ $this->assertSame(Http::STATUS_OK, $response->getStatus());
+ $body = $response->getData();
+ $this->assertSame('recipient@example.org', $body['recipientEmail']);
+ $this->assertSame(self::TOKEN, $body['token']);
+ $this->assertSame(1_800_000_000, $body['createdAt']);
+ }
+
+ public function testAttachEmailAndSendRejectsWhenClaimLosesRace(): void {
+ $invite = $this->makeInvite(null);
+
+ $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite);
+ $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]);
+ $this->mailer->method('validateMailAddress')->willReturn(true);
+ $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000);
+ $now = $this->createMock(\DateTimeImmutable::class);
+ $now->method('getTimestamp')->willReturn(1_800_000_000);
+ $this->timeFactory->method('now')->willReturn($now);
+
+ $this->mapper->expects($this->once())
+ ->method('claimInviteForEmail')
+ ->willReturn(false);
+ $this->mailer->expects($this->never())->method('send');
+ $this->mapper->expects($this->never())->method('revertInviteEmail');
+
+ $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org');
+
+ $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus());
+ $this->assertSame('ocm_invite_claim_failed', $response->getData()['code']);
+ $this->assertNull($invite->getRecipientEmail());
+ }
+
+ public function testAttachEmailAndSendReturnsClaimExceptionCodeWhenClaimFails(): void {
+ $invite = $this->makeInvite(null);
+
+ $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite);
+ $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]);
+ $this->mailer->method('validateMailAddress')->willReturn(true);
+ $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000);
+ $now = $this->createMock(\DateTimeImmutable::class);
+ $now->method('getTimestamp')->willReturn(1_800_000_000);
+ $this->timeFactory->method('now')->willReturn($now);
+
+ $this->mapper->expects($this->once())
+ ->method('claimInviteForEmail')
+ ->willThrowException(new \RuntimeException('claim boom'));
+ $this->mailer->expects($this->never())->method('send');
+ $this->mapper->expects($this->never())->method('revertInviteEmail');
+
+ $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org');
+
+ $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus());
+ $this->assertSame('ocm_invite_claim_exception', $response->getData()['code']);
+ }
+
+ public function testAttachEmailAndSendRejectsWhenInviteBelongsToAnotherUser(): void {
+ $this->mapper->expects($this->once())
+ ->method('findInviteByTokenAndUid')
+ ->with(self::TOKEN, self::UID)
+ ->willThrowException(new DoesNotExistException('not found'));
+
+ $this->mailer->expects($this->never())->method('send');
+ $this->mapper->expects($this->never())->method('claimInviteForEmail');
+ $this->mapper->expects($this->never())->method('revertInviteEmail');
+
+ $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org');
+
+ $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus());
+ }
+
+ public function testAttachEmailAndSendRejectsWhenInviteAlreadyAccepted(): void {
+ $invite = $this->makeInvite(null, accepted: true);
+ $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite);
+
+ $this->mailer->expects($this->never())->method('send');
+ $this->mapper->expects($this->never())->method('claimInviteForEmail');
+
+ $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org');
+
+ $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus());
+ $this->assertSame('ocm_invite_already_accepted', $response->getData()['code']);
+ }
+
+ public function testAttachEmailAndSendRejectsWhenInviteAlreadyHasEmail(): void {
+ $invite = $this->makeInvite('existing@example.org');
+ $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite);
+
+ $this->mailer->expects($this->never())->method('send');
+ $this->mapper->expects($this->never())->method('claimInviteForEmail');
+
+ $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org');
+
+ $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus());
+ $this->assertSame('ocm_invite_already_has_email', $response->getData()['code']);
+ }
+
+ public function testAttachEmailAndSendRejectsInvalidEmail(): void {
+ $invite = $this->makeInvite(null);
+ $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite);
+ $this->mailer->method('validateMailAddress')->willReturn(false);
+
+ $this->mailer->expects($this->never())->method('send');
+ $this->mapper->expects($this->never())->method('claimInviteForEmail');
+
+ $response = $this->controller->attachEmailAndSend(self::TOKEN, 'not-an-email');
+
+ $this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $response->getStatus());
+ $this->assertNull($invite->getRecipientEmail());
+ }
+
+ public function testAttachEmailAndSendRejectsCollidingOpenInvite(): void {
+ $invite = $this->makeInvite(null);
+ $other = $this->makeInvite('recipient@example.org');
+ $other->setToken('other-token');
+
+ $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite);
+ $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([$other]);
+ $this->mailer->method('validateMailAddress')->willReturn(true);
+
+ $this->mailer->expects($this->never())->method('send');
+ $this->mapper->expects($this->never())->method('claimInviteForEmail');
+
+ $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org');
+
+ $this->assertSame(Http::STATUS_CONFLICT, $response->getStatus());
+ $this->assertSame('ocm_invite_duplicate_recipient_email', $response->getData()['code']);
+ }
+
+ public function testAttachEmailAndSendRevertsOnMailerFailure(): void {
+ $invite = $this->makeInvite(null);
+ $originalCreatedAt = $invite->getCreatedAt();
+ $originalExpiredAt = $invite->getExpiredAt();
+
+ $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite);
+ $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]);
+ $this->mailer->method('validateMailAddress')->willReturn(true);
+ $this->mailer->method('createMessage')->willReturn($this->createMock(\OCP\Mail\IMessage::class));
+ $this->mailer->method('send')->willReturn(['recipient@example.org']);
+ $this->wayfProvider->method('getWayfEndpoint')->willReturn('https://example.org/wayf');
+ $this->invitesService->method('getProviderFQDN')->willReturn('example.org');
+ $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000);
+ $now = $this->createMock(\DateTimeImmutable::class);
+ $now->method('getTimestamp')->willReturn(1_800_000_000);
+ $this->timeFactory->method('now')->willReturn($now);
+
+ $this->mapper->expects($this->once())
+ ->method('claimInviteForEmail')
+ ->willReturn(true);
+ $this->mapper->expects($this->once())
+ ->method('revertInviteEmail')
+ ->with(self::TOKEN, self::UID, 'recipient@example.org', $originalCreatedAt, $originalExpiredAt)
+ ->willReturn(true);
+
+ $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org');
+
+ $this->assertNotSame(Http::STATUS_OK, $response->getStatus());
+ }
+
+ public function testAttachEmailAndSendReturnsRevertFailureWhenRevertMisses(): void {
+ $invite = $this->makeInvite(null);
+ $originalCreatedAt = $invite->getCreatedAt();
+ $originalExpiredAt = $invite->getExpiredAt();
+
+ $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite);
+ $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]);
+ $this->mailer->method('validateMailAddress')->willReturn(true);
+ $this->mailer->method('createMessage')->willReturn($this->createMock(\OCP\Mail\IMessage::class));
+ $this->mailer->method('send')->willReturn(['recipient@example.org']);
+ $this->wayfProvider->method('getWayfEndpoint')->willReturn('https://example.org/wayf');
+ $this->invitesService->method('getProviderFQDN')->willReturn('example.org');
+ $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000);
+ $now = $this->createMock(\DateTimeImmutable::class);
+ $now->method('getTimestamp')->willReturn(1_800_000_000);
+ $this->timeFactory->method('now')->willReturn($now);
+
+ $this->mapper->expects($this->once())
+ ->method('claimInviteForEmail')
+ ->willReturn(true);
+ $this->mapper->expects($this->once())
+ ->method('revertInviteEmail')
+ ->with(self::TOKEN, self::UID, 'recipient@example.org', $originalCreatedAt, $originalExpiredAt)
+ ->willReturn(false);
+
+ $response = $this->controller->attachEmailAndSend(self::TOKEN, 'recipient@example.org');
+
+ $this->assertSame(Http::STATUS_BAD_GATEWAY, $response->getStatus());
+ $body = $response->getData();
+ $this->assertSame('ocm_invite_revert_failed', $body['code']);
+ $this->assertNotEmpty($body['mailError']);
+ }
+
+ public function testAttachEmailAndSendEscapesUserContentInHtmlBody(): void {
+ $invite = $this->makeInvite(null);
+ $this->mapper->method('findInviteByTokenAndUid')->willReturn($invite);
+ $this->mapper->method('findOpenInvitesByRecipientEmail')->willReturn([]);
+ $this->mailer->method('validateMailAddress')->willReturn(true);
+ $this->wayfProvider->method('getWayfEndpoint')->willReturn('https://example.org/wayf');
+ $this->invitesService->method('getProviderFQDN')->willReturn('example.org');
+ $this->invitesService->method('getInviteExpirationDate')->willReturnCallback(static fn (int $t): int => $t + 2_592_000);
+ $now = $this->createMock(\DateTimeImmutable::class);
+ $now->method('getTimestamp')->willReturn(1_800_000_000);
+ $this->timeFactory->method('now')->willReturn($now);
+ $this->mapper->method('claimInviteForEmail')->willReturn(true);
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn(self::UID);
+ $user->method('getDisplayName')->willReturn('Eve