diff --git a/appinfo/info.xml b/appinfo/info.xml
index 249c4c20b3..cb6c9d3adc 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -49,8 +49,13 @@
OCA\Contacts\Cron\SocialUpdateRegistration
+ OCA\Contacts\Cron\UpdateOcmProviders
+
+ OCA\Contacts\Command\OcmInvitesConfig
+
+
OCA\Contacts\Settings\AdminSettings
diff --git a/jest.config.cjs b/jest.config.cjs
index 168bf17c54..86088350fc 100644
--- a/jest.config.cjs
+++ b/jest.config.cjs
@@ -10,7 +10,7 @@ module.exports = {
preset: 'ts-jest',
moduleFileExtensions: ['js', 'vue', 'ts'],
collectCoverageFrom: [
- 'src/**/*.{js,vue}',
+ 'src/**/*.{js,ts,vue}',
'!**/node_modules/**',
],
setupFilesAfterEnv: [
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index ac43828a3c..6a951b2785 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -7,10 +7,13 @@
namespace OCA\Contacts\AppInfo;
use OCA\Contacts\Capabilities;
+use OCA\Contacts\ConfigLexicon;
use OCA\Contacts\Dav\PatchPlugin;
use OCA\Contacts\Event\LoadContactsOcaApiEvent;
+use OCA\Contacts\Listener\FederatedInviteAcceptedListener;
use OCA\Contacts\Listener\LoadContactsFilesActions;
use OCA\Contacts\Listener\LoadContactsOcaApi;
+use OCA\Contacts\Listener\OcmDiscoveryListener;
use OCA\DAV\Events\SabrePluginAddEvent;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCP\AppFramework\App;
@@ -18,6 +21,9 @@
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\EventDispatcher\IEventDispatcher;
+use OCP\OCM\Events\LocalOCMDiscoveryEvent;
+use OCP\OCM\Events\OCMEndpointRequestEvent;
+use OCP\OCM\Events\ResourceTypeRegisterEvent;
class Application extends App implements IBootstrap {
public const APP_ID = 'contacts';
@@ -33,8 +39,15 @@ public function __construct() {
#[\Override]
public function register(IRegistrationContext $context): void {
$context->registerCapability(Capabilities::class);
+ $context->registerConfigLexicon(ConfigLexicon::class);
+
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadContactsFilesActions::class);
$context->registerEventListener(LoadContactsOcaApiEvent::class, LoadContactsOcaApi::class);
+ $context->registerEventListener(OCMEndpointRequestEvent::class, FederatedInviteAcceptedListener::class);
+ $ocmDiscoveryEvent = class_exists(LocalOCMDiscoveryEvent::class)
+ ? LocalOCMDiscoveryEvent::class
+ : ResourceTypeRegisterEvent::class;
+ $context->registerEventListener($ocmDiscoveryEvent, OcmDiscoveryListener::class);
}
#[\Override]
diff --git a/lib/Command/OcmInvitesConfig.php b/lib/Command/OcmInvitesConfig.php
new file mode 100644
index 0000000000..dbb17d3b27
--- /dev/null
+++ b/lib/Command/OcmInvitesConfig.php
@@ -0,0 +1,189 @@
+
+ * occ contacts:ocm-invites-config
+ *
+ * Boolean values accept on/off, true/false, 1/0, and yes/no.
+ */
+class OcmInvitesConfig extends Command {
+ public function __construct(
+ private IAppConfig $appConfig,
+ ) {
+ parent::__construct();
+ }
+
+ #[\Override]
+ protected function configure(): void {
+ $supportedOptions = implode(', ', $this->getSupportedOptions());
+ $help = <<%command.name% command reads and writes Contacts OCM invite settings.
+
+Supported keys:
+ {$supportedOptions}
+
+Key info:
+ ocm_invites_enabled - If set to true then the capability to send and accept invitations to exchange contact info is enabled in the app - bool, default false
+ ocm_invites_optional_mail - If set to true then sending an invitation by email is optional - bool, default false
+ ocm_invites_cc_sender - If set to true then the option to send a copy of the invitation to the sender is displayed - bool, default true
+ ocm_invites_encoded_copy_button - If set to true then the button to copy the encoded invitation is displayed - bool, default false
+ ocm_invites_disable_ssrf_guard - If set to true SSRF guard will be turned off. Warning: This is for development/testing purposes only!
+ In production environments the value of this key should always be false - bool, default false
+ mesh_providers_service - The url that returns the list of mesh providers that will be displayed on the WAYF page - string, default empty
+
+Boolean values accept: on/off, true/false, 1/0, yes/no.
+
+Examples:
+ occ %command.name%
+ occ %command.name% ocm_invites_enabled on
+ occ %command.name% mesh_providers_service "https://mesh.example"
+
+HELP;
+
+ $this
+ ->setName('contacts:ocm-invites-config')
+ ->setDescription('Manage OCM invite configuration.')
+ ->addArgument(
+ 'option',
+ InputArgument::OPTIONAL,
+ 'Config key to read or write. Omit to list supported keys.',
+ )
+ ->addArgument(
+ 'value',
+ InputArgument::OPTIONAL,
+ 'Value to write. Omit to read the current value.',
+ )
+ ->setHelp($help);
+ }
+
+ #[\Override]
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $option = $input->getArgument('option');
+ $value = $input->getArgument('value');
+
+ if ($option === null) {
+ return $this->listAll($output);
+ }
+
+ if (!in_array($option, $this->getSupportedOptions(), true)) {
+ $output->writeln(sprintf(
+ 'Unknown OCM invite config key "%s". Allowed: %s.',
+ $option,
+ implode(', ', $this->getSupportedOptions()),
+ ));
+ return self::FAILURE;
+ }
+
+ if ($value === null) {
+ $output->writeln($this->getCurrentValue($option));
+ return self::SUCCESS;
+ }
+
+ if ($option === ConfigLexicon::MESH_PROVIDERS_SERVICE) {
+ $normalised = trim($value);
+ $current = $this->appConfig->getValueString(Application::APP_ID, $option);
+ if ($current === $normalised) {
+ $output->writeln(sprintf('%s is already "%s".', $option, $normalised));
+ return self::SUCCESS;
+ }
+
+ $this->appConfig->setValueString(Application::APP_ID, $option, $normalised);
+ $output->writeln(sprintf('%s: "%s"', $option, $normalised));
+ return self::SUCCESS;
+ }
+
+ $parsed = $this->parseBool($value);
+ if ($parsed === null) {
+ $output->writeln(sprintf(
+ 'Cannot parse "%s" as boolean. Use on/off, true/false, 1/0, or yes/no.',
+ $value,
+ ));
+ return self::INVALID;
+ }
+
+ $current = $this->appConfig->getValueBool(Application::APP_ID, $option);
+ if ($current === $parsed) {
+ $output->writeln(sprintf('%s is already %s.', $option, $this->formatBool($parsed)));
+ return self::SUCCESS;
+ }
+
+ $this->appConfig->setValueBool(Application::APP_ID, $option, $parsed);
+ $output->writeln(sprintf('%s: %s', $option, $this->formatBool($parsed)));
+ return self::SUCCESS;
+ }
+
+ private function listAll(OutputInterface $output): int {
+ $table = new Table($output);
+ $table->setHeaders(['option', 'type', 'value']);
+ foreach ($this->getSupportedOptions() as $key) {
+ $table->addRow([
+ $key,
+ $this->isBooleanOption($key) ? 'bool' : 'string',
+ $this->getCurrentValue($key),
+ ]);
+ }
+ $table->render();
+ return self::SUCCESS;
+ }
+
+ private function getSupportedOptions(): array {
+ return [
+ ConfigLexicon::OCM_INVITES_ENABLED,
+ ...FederatedInvitesService::OCM_INVITES_BOOL_KEYS,
+ ConfigLexicon::MESH_PROVIDERS_SERVICE,
+ ];
+ }
+
+ private function isBooleanOption(string $option): bool {
+ return in_array($option, [
+ ConfigLexicon::OCM_INVITES_ENABLED,
+ ...FederatedInvitesService::OCM_INVITES_BOOL_KEYS,
+ ], true);
+ }
+
+ private function getCurrentValue(string $option): string {
+ if ($this->isBooleanOption($option)) {
+ return $this->formatBool($this->appConfig->getValueBool(Application::APP_ID, $option));
+ }
+
+ return $this->appConfig->getValueString(Application::APP_ID, $option);
+ }
+
+ private function formatBool(bool $value): string {
+ return $value ? 'on' : 'off';
+ }
+
+ private function parseBool(string $raw): ?bool {
+ $normalised = strtolower(trim($raw));
+ if (in_array($normalised, ['true', '1', 'on', 'yes'], true)) {
+ return true;
+ }
+ if (in_array($normalised, ['false', '0', 'off', 'no'], true)) {
+ return false;
+ }
+ return null;
+ }
+}
diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php
new file mode 100644
index 0000000000..e0f6186aeb
--- /dev/null
+++ b/lib/ConfigLexicon.php
@@ -0,0 +1,109 @@
+requireOcmInvitesEnabled()) !== null) {
+ return $disabled;
+ }
+
+ $uid = $this->userSession->getUser()->getUID();
+ try {
+ $_invites = $this->federatedInviteMapper->findOpenInvitesByUid($uid);
+ $invites = [];
+ foreach ($_invites as $invite) {
+ if ($invite instanceof FederatedInvite) {
+ array_push(
+ $invites,
+ $invite->jsonSerialize()
+ );
+ }
+ }
+ return new JSONResponse($invites, Http::STATUS_OK);
+ } catch (Exception $e) {
+ $this->logger->error("An unexpected error occurred loading invites for user with uid=$uid. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse([
+ 'code' => 'ocm_invites_fetch_failed',
+ 'message' => 'Could not load invites.',
+ ], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ /**
+ * Deletes the invite with the specified token.
+ *
+ * @param string $token the token of the invite to delete
+ * @return JSONResponse with data signature ['token' | 'message'] - the token of the deleted invitation or an error message in case of error
+ */
+ #[NoAdminRequired]
+ #[FrontpageRoute(verb: 'DELETE', url: '/ocm/invitations/{token}')]
+ public function deleteInvite(string $token): JSONResponse {
+ if (($disabled = $this->requireOcmInvitesEnabled()) !== null) {
+ return $disabled;
+ }
+
+ try {
+ $uid = $this->userSession->getUser()->getUID();
+ $invite = $this->federatedInviteMapper->findInviteByTokenAndUid($token, $uid);
+ $this->federatedInviteMapper->delete($invite);
+ return new JSONResponse(['token' => $token], Http::STATUS_OK);
+ } catch (DoesNotExistException $e) {
+ $this->logger->warning("Could not find invite with token=$token for user with uid=$uid", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'Invite not found'], Http::STATUS_NOT_FOUND);
+ } catch (Exception $e) {
+ $this->logger->error("An unexpected error occurred deleting invite with token=$token. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred trying to delete the invite'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ /**
+ * Results in displaying the invite accept dialog upon following the invite link.
+ *
+ * @param string $token
+ * @param string $providerDomain
+ * @return TemplateResponse
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[FrontpageRoute(verb: 'GET', url: FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE)]
+ public function inviteAcceptDialog(string $token = '', string $providerDomain = ''): TemplateResponse {
+ $this->initialState->provideInitialState('inviteToken', $token);
+ $this->initialState->provideInitialState('inviteProvider', $providerDomain);
+ $this->initialState->provideInitialState('acceptInviteDialogUrl', FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE);
+
+ return $this->index();
+ }
+
+ /**
+ * Creates an invitation to exchange contact info with the remote user.
+ *
+ * @param string $email the recipient email address to send the invitation to (optional)
+ * @param string $message the optional message to send with the invitation
+ * @param string $note optional note/label for identifying the invite
+ * @param bool $ccSender whether to send a copy of the invite to the sender
+ * @return JSONResponse with data signature ['invite' | 'message'] - the invite url or an error message in case of error.
+ */
+ #[NoAdminRequired]
+ #[UserRateLimit(limit: 60, period: 3600)]
+ #[BruteForceProtection(action: 'ocmInviteCreate')]
+ #[FrontpageRoute(verb: 'POST', url: '/ocm/invitations')]
+ public function createInvite(string $email = '', string $message = '', string $note = '', bool $ccSender = false): JSONResponse {
+ if (($disabled = $this->requireOcmInvitesEnabled()) !== null) {
+ return $disabled;
+ }
+
+ // Enforce email required when optional mail is disabled
+ if (empty($email) && !$this->federatedInvitesService->isOptionalMailEnabled()) {
+ return new JSONResponse(['message' => $this->il10->t('Email address is required.')], Http::STATUS_BAD_REQUEST);
+ }
+
+ $uid = $this->userSession->getUser()->getUID();
+ if (!empty($email)) {
+ $validationError = $this->validateEmail($email);
+ if ($validationError !== null) {
+ return $validationError;
+ }
+
+ $this->cleanupSupersededInvitesForRecipient($uid, $email);
+
+ // check for existing open invite for the specified email, only if email provided
+ $existingInvites = $this->federatedInviteMapper->findOpenInvitesByRecipientEmail(
+ $uid,
+ $email,
+ );
+ if (count($existingInvites) > 0) {
+ $this->logger->error("An open invite already exists for user with uid $uid and for recipient email $email", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => $this->il10->t('An open invite already exists.')], Http::STATUS_CONFLICT);
+ }
+ }
+
+ $invite = new FederatedInvite();
+ $invite->setUserId($uid);
+ $token = UUIDUtil::getUUID();
+ $invite->setToken($token);
+ // created-/expiredAt in seconds
+ $invite->setCreatedAt($this->timeFactory->now()->getTimestamp());
+ $invite->setExpiredAt($this->federatedInvitesService->getInviteExpirationDate($invite->getCreatedAt()));
+ if (!empty($email)) {
+ $invite->setRecipientEmail($email);
+ }
+ // Store note in recipientName field (used as label until invite is accepted)
+ if (!empty($note)) {
+ $invite->setRecipientName($note);
+ }
+ $invite->setAccepted(false);
+ $inserted = false;
+ try {
+ $this->federatedInviteMapper->insert($invite);
+ $inserted = true;
+ } catch (Throwable $e) {
+ if ($this->isDuplicateConstraintException($e)) {
+ if (!empty($email) && $this->cleanupSupersededInvitesForRecipient($uid, $email) > 0) {
+ try {
+ $this->federatedInviteMapper->insert($invite);
+ $inserted = true;
+ } catch (Throwable $retry) {
+ if (!$this->isDuplicateConstraintException($retry)) {
+ $this->logger->error('An unexpected error occurred saving a new invite after stale cleanup. Stacktrace: ' . $retry->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred creating the invite.'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+ }
+ if (!$inserted) {
+ return new JSONResponse(['message' => $this->il10->t('An open invite already exists.')], Http::STATUS_CONFLICT);
+ }
+ } else {
+ $this->logger->error('An unexpected error occurred saving a new invite. Stacktrace: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred creating the invite.'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+ $senderProvider = $this->federatedInvitesService->getProviderFQDN();
+
+ // Only send email if email address provided
+ if (!empty($email)) {
+ /** @var JSONResponse */
+ $response = $this->sendEmail($token, $senderProvider, $email, $message);
+ if ($response->getStatus() !== Http::STATUS_OK) {
+ // delete invite in case sending the email has failed
+ try {
+ $this->federatedInviteMapper->delete($invite);
+ } catch (Exception $e) {
+ $this->logger->error("An unexpected error occurred deleting invite with token $token. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred creating the invite.'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ return $response;
+ }
+
+ // Send CC to sender if requested and enabled
+ if ($ccSender && $this->federatedInvitesService->isCcSenderEnabled()) {
+ $senderEmail = $this->userSession->getUser()->getEMailAddress();
+ if (!empty($senderEmail)) {
+ $this->sendCcEmail($token, $senderProvider, $senderEmail, $email, $message);
+ }
+ }
+ }
+
+ // invite url, use token instead of email for routing
+ $inviteUrl = $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->linkToRoute('contacts.page.index') . 'ocm-invites/' . $token
+ );
+ return new JSONResponse(['invite' => $inviteUrl], Http::STATUS_OK);
+ }
+
+ /**
+ * Accepts the invite and creates a new contact from the inviter.
+ * On success the user is redirected to the new contact url.
+ *
+ * @param string $token the token of the invite
+ * @param string $provider the provider of the sender of the invite
+ * @return JSONResponse with data signature ['contact' | 'message'] - the new contact url or an error message in case of error
+ */
+ #[NoAdminRequired]
+ #[UserRateLimit(limit: 60, period: 3600)]
+ #[BruteForceProtection(action: 'ocmInviteAccept')]
+ #[FrontpageRoute(verb: 'PATCH', url: '/ocm/invitations/{token}/accept')]
+ public function acceptInvite(string $token = '', string $provider = ''): JSONResponse {
+ if (($disabled = $this->requireOcmInvitesEnabled()) !== null) {
+ return $disabled;
+ }
+
+ if ($token === '' || $provider === '') {
+ $this->logger->error("Both token and provider must be specified. Received: token=$token, provider=$provider", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'Both invite code and provider must be specified.'], Http::STATUS_BAD_REQUEST);
+ }
+ $localUser = $this->userSession->getUser();
+ if ($localUser === null) {
+ return new JSONResponse(['message' => $this->il10->t('Could not accept invite because no authenticated user was found.')], Http::STATUS_UNAUTHORIZED);
+ }
+ $provider = $this->normalizeProviderBase($provider);
+ if ($provider === null) {
+ return new JSONResponse(['message' => $this->il10->t('The invite provider is invalid or not allowed.')], Http::STATUS_BAD_REQUEST);
+ }
+ $recipientProvider = $this->federatedInvitesService->getProviderFQDN();
+ $userId = $localUser->getUID();
+ $email = $localUser->getEMailAddress();
+ $name = $localUser->getDisplayName();
+ if ($recipientProvider === '' || $userId === '' || $email === '' || $name === '') {
+ $this->logger->error("All of these must be set: recipientProvider: $recipientProvider, email: $email, userId: $userId, name: $name", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'Could not accept invite, user data is incomplete.'], Http::STATUS_UNPROCESSABLE_ENTITY);
+ }
+ $cloudId = '';
+ try {
+ // accept the invite by calling provider OCM /invite-accepted
+ // this returns a response with the following data signature: ['userID', 'email', 'name']
+ // @link https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post
+ $client = $this->httpClient->newClient();
+ /**
+ * @var \OCP\OCM\ICapabilityAwareOCMProvider $discovered
+ *
+ */
+ $discovered = $this->discovery->discover($provider);
+ $capabilities = $discovered->getCapabilities();
+ // Accept both the canonical advertised capability and older aliases.
+ if (
+ in_array('invites', $capabilities, true)
+ || in_array('invite-accepted', $capabilities, true)
+ || in_array('/invite-accepted', $capabilities, true)
+ ) {
+
+ $response = $this->discovery->requestRemoteOcmEndpoint(
+ null,
+ $provider,
+ '/invite-accepted',
+ [
+ 'recipientProvider' => $recipientProvider,
+ 'token' => $token,
+ 'userID' => $userId,
+ 'email' => $email,
+ 'name' => $name
+ ],
+ 'POST',
+ $client
+ );
+ $responseData = $response->getBody();
+ $data = json_decode($responseData, true);
+ if (
+ !is_array($data)
+ || !isset($data['userID'], $data['email'], $data['name'])
+ || !is_string($data['userID'])
+ || !is_string($data['email'])
+ || !is_string($data['name'])
+ || trim($data['userID']) === ''
+ || trim($data['email']) === ''
+ || trim($data['name']) === ''
+ ) {
+ $this->logger->warning('Invalid /invite-accepted payload from provider', [
+ 'app' => Application::APP_ID,
+ 'provider' => $provider,
+ 'payload' => $responseData,
+ ]);
+ return new JSONResponse(['message' => $this->il10->t('Could not accept invite because the remote provider returned an invalid response.')], Http::STATUS_BAD_GATEWAY);
+ }
+
+ $cloudId = $data['userID'] . '@' . $this->addressHandler->removeProtocolFromUrl($provider);
+
+ $contactRef = $this->federatedInvitesService->createNewContact(
+ $cloudId,
+ $data['email'],
+ $data['name'],
+ null
+ );
+ if (!isset($contactRef)) {
+ $this->logger->error('Remote invite acceptance succeeded but local contact creation failed', [
+ 'app' => Application::APP_ID,
+ 'token' => $token,
+ 'provider' => $provider,
+ 'cloudId' => $cloudId,
+ 'userId' => $userId,
+ ]);
+ return new JSONResponse([
+ 'code' => 'ocm_invite_local_contact_create_failed',
+ 'message' => $this->il10->t('The remote provider accepted the invite, but this server could not create the local contact.'),
+ ], Http::STATUS_BAD_GATEWAY);
+ }
+ $key = base64_encode($contactRef);
+ $contactUrl = $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->linkToRoute('contacts.page.index') . $this->il10->t('All contacts') . '/' . $key
+ );
+ return new JSONResponse(['contact' => $contactUrl], Http::STATUS_OK);
+ } else {
+ $this->logger->error('Provider: ' . $provider . ' does not support invites.', ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'Provider: ' . $provider . ' does not support invites.'], Http::STATUS_BAD_REQUEST);
+ }
+ } catch (ContactExistsException $e) {
+ return new JSONResponse(['message' => 'Contact with cloudID ' . $cloudId . ' already exists.'], Http::STATUS_CONFLICT);
+ } catch (RequestException $e) { // this should catch OCM API request exceptions
+ $this->logger->error('/invite-accepted returned an error: ' . $e->getMessage(), ['app' => Application::APP_ID]);
+ /**
+ * 400: Invalid or non existing token
+ * 409: Invite already accepted
+ */
+ $statusCode = $e->getResponse() !== null
+ ? $e->getResponse()->getStatusCode()
+ : null;
+ switch ($statusCode) {
+ case Http::STATUS_BAD_REQUEST:
+ return new JSONResponse(['message' => 'Invalid, non-existing, or expired invite code.'], Http::STATUS_BAD_REQUEST);
+ case Http::STATUS_CONFLICT:
+ return new JSONResponse(['message' => 'Invite already accepted'], Http::STATUS_CONFLICT);
+ }
+ $this->logger->error("An unexpected error occurred accepting invite with token=$token and provider=$provider. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred trying to accept invite.'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ } catch (OCMProviderException|OCMRequestException|Exception $e) {
+ $this->logger->error("An unexpected error occurred accepting invite with token=$token and provider=$provider. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred trying to accept invite'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ /**
+ * Re-sends an existing invite email while preserving invite lifetime metadata.
+ *
+ *
+ */
+ #[NoAdminRequired]
+ #[UserRateLimit(limit: 30, period: 3600)]
+ #[BruteForceProtection(action: 'ocmInviteResend')]
+ #[FrontpageRoute(verb: 'PATCH', url: '/ocm/invitations/{token}/resend')]
+ public function resendInvite(string $token): JSONResponse {
+ if (($disabled = $this->requireOcmInvitesEnabled()) !== null) {
+ return $disabled;
+ }
+
+ $uid = $this->userSession->getUser()->getUID();
+ try {
+ $invite = $this->federatedInviteMapper->findInviteByTokenAndUid($token, $uid);
+ } catch (DoesNotExistException $e) {
+ $this->logger->warning("Could not find invite with token=$token for user with uid=$uid", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'Invite not found'], Http::STATUS_NOT_FOUND);
+ } catch (Exception $e) {
+ $this->logger->error("An unexpected error occurred loading invite with token=$token. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred trying to resend the invite'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ // Cannot resend if no email address
+ if (empty($invite->getRecipientEmail())) {
+ return new JSONResponse(['message' => $this->il10->t('Cannot resend: no email address')], Http::STATUS_UNPROCESSABLE_ENTITY);
+ }
+ if ($this->isInviteAccepted($invite)) {
+ return new JSONResponse([
+ 'code' => 'ocm_invite_already_accepted',
+ 'message' => $this->il10->t('Invite has already been accepted.'),
+ ], Http::STATUS_CONFLICT);
+ }
+ if ($this->isInviteExpired($invite)) {
+ return new JSONResponse([
+ 'code' => 'ocm_invite_expired',
+ 'message' => $this->il10->t('Invite has expired. Please create a new one.'),
+ ], Http::STATUS_GONE);
+ }
+
+ $sendDate = date('Y-m-d', $invite->getCreatedAt());
+ $initiatorDisplayName = $this->userSession->getUser()->getDisplayName();
+ // a resend notification that refers to the previously sent invite
+ $message = $this->il10->t(
+ 'This is a copy of an invite sent to you previously by %1$s on %2$s',
+ [
+ $initiatorDisplayName,
+ $sendDate
+ ]
+ );
+ $senderProvider = $this->federatedInvitesService->getProviderFQDN();
+ /** @var JSONResponse */
+ $response = $this->sendEmail($token, $senderProvider, $invite->getRecipientEmail(), $message);
+ if ($response->getStatus() !== Http::STATUS_OK) {
+ $this->logger->error("An unexpected error occurred resending the invite with token $token. HTTP response status: " . $response->getStatus(), ['app' => Application::APP_ID]);
+ return $response;
+ }
+
+ // the invite url, use token instead of email for routing
+ $inviteUrl = $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->linkToRoute('contacts.page.index') . 'ocm-invites/' . $invite->getToken()
+ );
+ return new JSONResponse(['invite' => $inviteUrl], Http::STATUS_OK);
+ }
+
+ /**
+ * Attaches a recipient email to an existing link-only invite and sends the
+ * invitation email. Refreshes the creation and expiration timestamps so the
+ * recipient receives a fresh expiry window. Reverts both the email and the
+ * timestamps if the mailer fails, so a failed call leaves the invite as it
+ * was before.
+ *
+ * @param string $token the invite token
+ * @param string $email the recipient email address
+ * @param string $message the optional message to include in the email
+ * @return JSONResponse the serialized invite on success or an error message
+ */
+ #[NoAdminRequired]
+ #[UserRateLimit(limit: 30, period: 3600)]
+ #[BruteForceProtection(action: 'ocmInviteAttachEmail')]
+ #[FrontpageRoute(verb: 'PATCH', url: '/ocm/invitations/{token}/email')]
+ public function attachEmailAndSend(string $token, string $email = '', string $message = ''): JSONResponse {
+ if (($disabled = $this->requireOcmInvitesEnabled()) !== null) {
+ return $disabled;
+ }
+
+ $uid = $this->userSession->getUser()->getUID();
+ try {
+ $invite = $this->federatedInviteMapper->findInviteByTokenAndUid($token, $uid);
+ } catch (DoesNotExistException $e) {
+ $this->logger->warning("Could not find invite with token=$token for user with uid=$uid", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'Invite not found'], Http::STATUS_NOT_FOUND);
+ } catch (Exception $e) {
+ $this->logger->error("An unexpected error occurred loading invite with token=$token. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'An unexpected error occurred attaching the email.'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ if ($this->isInviteAccepted($invite)) {
+ return new JSONResponse([
+ 'code' => 'ocm_invite_already_accepted',
+ 'message' => $this->il10->t('Invite has already been accepted.'),
+ ], Http::STATUS_CONFLICT);
+ }
+ if (!empty($invite->getRecipientEmail())) {
+ return new JSONResponse([
+ 'code' => 'ocm_invite_already_has_email',
+ 'message' => $this->il10->t('Invite already has an email address.'),
+ ], Http::STATUS_CONFLICT);
+ }
+
+ if (empty($email)) {
+ return new JSONResponse([
+ 'code' => 'ocm_invite_email_required',
+ 'message' => $this->il10->t('Email address is required.'),
+ ], Http::STATUS_BAD_REQUEST);
+ }
+ $validationError = $this->validateEmail($email);
+ if ($validationError !== null) {
+ return $validationError;
+ }
+
+ $this->cleanupSupersededInvitesForRecipient($uid, $email);
+
+ // Reject when another open invite from this user already targets the same email.
+ // The current invite is excluded by construction: it has no recipient_email yet
+ // and findOpenInvitesByRecipientEmail() filters by recipient_email.
+ $existingInvites = $this->federatedInviteMapper->findOpenInvitesByRecipientEmail($uid, $email);
+ if (count($existingInvites) > 0) {
+ $this->logger->error("An open invite already exists for user with uid $uid and for recipient email $email", ['app' => Application::APP_ID]);
+ return new JSONResponse([
+ 'code' => 'ocm_invite_duplicate_recipient_email',
+ 'message' => $this->il10->t('An open invite for this email already exists.'),
+ ], Http::STATUS_CONFLICT);
+ }
+
+ $previousCreatedAt = $invite->getCreatedAt();
+ $previousExpiredAt = $invite->getExpiredAt();
+ $newCreatedAt = $this->timeFactory->now()->getTimestamp();
+ $newExpiredAt = $this->federatedInvitesService->getInviteExpirationDate($newCreatedAt);
+
+ try {
+ $claimed = $this->federatedInviteMapper->claimInviteForEmail(
+ $token,
+ $uid,
+ $email,
+ $newCreatedAt,
+ $newExpiredAt,
+ );
+ } catch (Exception $e) {
+ $this->logger->error("An unexpected error occurred claiming invite with token=$token. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ return new JSONResponse([
+ 'code' => 'ocm_invite_claim_exception',
+ 'message' => 'An unexpected error occurred attaching the email.',
+ ], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ if ($claimed === false) {
+ // A concurrent attach won the race or the invite was accepted between
+ // the read and the conditional update. Treat as a 409 collision so the
+ // client can refresh and decide what to do next.
+ return new JSONResponse([
+ 'code' => 'ocm_invite_claim_failed',
+ 'message' => $this->il10->t('Could not claim invite for this email; please refresh and try again.'),
+ ], Http::STATUS_CONFLICT);
+ }
+
+ $invite->setRecipientEmail($email);
+ $invite->setCreatedAt($newCreatedAt);
+ $invite->setExpiredAt($newExpiredAt);
+
+ $senderProvider = $this->federatedInvitesService->getProviderFQDN();
+ /** @var JSONResponse */
+ $response = $this->sendEmail($token, $senderProvider, $email, $message);
+ if ($response->getStatus() !== Http::STATUS_OK) {
+ $this->logger->error("An unexpected error occurred sending the invite with token $token. HTTP response status: " . $response->getStatus(), ['app' => Application::APP_ID]);
+ $reverted = false;
+ try {
+ $reverted = $this->federatedInviteMapper->revertInviteEmail(
+ $token,
+ $uid,
+ $email,
+ $previousCreatedAt,
+ $previousExpiredAt,
+ );
+ } catch (Exception $e) {
+ $this->logger->error("Could not revert invite with token=$token after mailer failure. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ }
+ if ($reverted !== true) {
+ $mailFailure = $response->getData();
+ $mailMessage = is_array($mailFailure) && isset($mailFailure['message']) && is_string($mailFailure['message'])
+ ? $mailFailure['message']
+ : null;
+ return new JSONResponse([
+ 'code' => 'ocm_invite_revert_failed',
+ 'message' => $this->il10->t('Could not revert invite after delivery failure. Please refresh and try again.'),
+ 'mailError' => $mailMessage,
+ ], $response->getStatus());
+ }
+ return $response;
+ }
+
+ return new JSONResponse($invite->jsonSerialize(), Http::STATUS_OK);
+ }
+
+ /**
+ * Do OCM discovery on behalf of VUE frontend to avoid CSRF issues
+ * @param string $base base url to discover
+ * @return DataResponse
+ */
+ #[PublicPage]
+ #[AnonRateLimit(limit: 120, period: 3600)]
+ #[UserRateLimit(limit: 120, period: 3600)]
+ #[BruteForceProtection(action: 'ocmInviteDiscover')]
+ #[FrontpageRoute(verb: 'GET', url: '/discover')]
+ public function discover(string $base): DataResponse {
+ if (!$this->federatedInvitesService->isOcmInvitesEnabled()) {
+ return new DataResponse([
+ 'code' => 'ocm_invites_disabled',
+ 'error' => $this->il10->t('OCM invites are disabled.'),
+ ], Http::STATUS_FORBIDDEN);
+ }
+
+ $base = $this->normalizeProviderBase($base);
+ if ($base === null) {
+ return new DataResponse(['error' => 'invalid base'], Http::STATUS_BAD_REQUEST);
+ }
+
+ try {
+ /**
+ * @var \OCP\OCM\ICapabilityAwareOCMProvider $provider
+ *
+ */
+ $provider = $this->discovery->discover($base);
+ $dialog = trim((string)$provider->getInviteAcceptDialog());
+ $absolute = $dialog === '' ? null : $this->buildInviteAcceptDialogAbsolute($base, $dialog);
+ if ($absolute === null) {
+ $dialog = $this->wayfProvider->getInviteAcceptDialogPath();
+ $absolute = $this->buildInviteAcceptDialogAbsolute($base, $dialog);
+ }
+ if ($absolute === null) {
+ return new DataResponse(['error' => 'OCM discovery failed', 'base' => $base], Http::STATUS_NOT_FOUND);
+ }
+
+ $baseHost = parse_url($base, PHP_URL_HOST);
+ return new DataResponse([
+ 'base' => $base,
+ 'inviteAcceptDialog' => $dialog,
+ 'inviteAcceptDialogAbsolute' => $absolute,
+ 'providerDomain' => is_string($baseHost) ? $baseHost : '',
+ ], Http::STATUS_OK);
+ } catch (Throwable $e) {
+ $this->logger->warning('OCM discovery failed', [
+ 'app' => Application::APP_ID,
+ 'base' => $base,
+ 'exception' => $e,
+ ]);
+ return new DataResponse(['error' => 'OCM discovery failed', 'base' => $base], Http::STATUS_NOT_FOUND);
+ }
+ }
+
+ /**
+ * Accepts the invite and creates a new contact from the inviter.
+ * On success the user is redirected to the new contact url.
+ *
+ * @param string $token the token of the invite
+ * @param string $provider the provider of the sender of the invite
+ * @return TemplateResponse the WAYF page
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[FrontpageRoute(verb: 'GET', url: '/wayf')]
+ public function wayf(string $token = ''): TemplateResponse {
+ Util::addScript(Application::APP_ID, 'contacts-wayf');
+ Util::addStyle(Application::APP_ID, 'contacts-wayf');
+ try {
+ $federations = $this->wayfProvider->getMeshProvidersFromCache();
+ $providerDomain = trim((string)$this->request->getParam('providerDomain', ''));
+ if ($providerDomain === '') {
+ $baseHost = parse_url($this->urlGenerator->getBaseUrl(), PHP_URL_HOST);
+ $providerDomain = is_string($baseHost) ? $baseHost : '';
+ }
+ $this->initialState->provideInitialState('wayf', [
+ 'federations' => $federations,
+ 'providerDomain' => $providerDomain,
+ 'token' => $token,
+ ]);
+ } catch (Exception $e) {
+ $this->logger->error($e->getMessage() . ' Trace: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ }
+ $template = new TemplateResponse('contacts', 'wayf', [], TemplateResponse::RENDER_AS_GUEST);
+ return $template;
+ }
+
+ /**
+ * Sends a copy of the invite email to the sender.
+ *
+ * @param string $token the invite token
+ * @param string $senderProvider this provider
+ * @param string $senderAddress the sender's email address
+ * @param string $recipientAddress the original recipient's email address
+ * @param string $message the optional message included in the invite
+ */
+ private function sendCcEmail(string $token, string $senderProvider, string $senderAddress, string $recipientAddress, string $message): void {
+ try {
+ $email = $this->mailer->createMessage();
+ $email->setTo([$senderAddress]);
+
+ $instanceName = $this->defaults->getName();
+ $initiatorDisplayName = $this->userSession->getUser()->getDisplayName();
+ $senderName = $this->il10->t(
+ '%1$s via %2$s',
+ [$initiatorDisplayName, $instanceName]
+ );
+ $email->setFrom([Util::getDefaultEmailAddress($instanceName) => $senderName]);
+ $subject = $this->il10->t('[Copy] Invite sent to %1$s', [$recipientAddress]);
+ $email->setSubject($subject);
+
+ $wayfEndpoint = $this->wayfProvider->getWayfEndpoint();
+ if ($wayfEndpoint === null || trim($wayfEndpoint) === '') {
+ return;
+ }
+ $inviteLink = $this->buildWayfInviteLink($wayfEndpoint, $token, $senderProvider);
+ $encoded = base64_encode("$token@$senderProvider");
+
+ $recipientH = htmlspecialchars($recipientAddress, ENT_QUOTES, 'UTF-8');
+ $inviteLinkH = htmlspecialchars($inviteLink, ENT_QUOTES, 'UTF-8');
+ $tokenSenderH = htmlspecialchars("$token@$senderProvider", ENT_QUOTES, 'UTF-8');
+ $encodedH = htmlspecialchars($encoded, ENT_QUOTES, 'UTF-8');
+ $messageH = nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), false);
+
+ $header = $this->il10->t('This is a copy of the invite you sent to %1$s.
', [$recipientH]);
+ $inviteLinkNote = $this->il10->t('Invite link: %1$s ', [$inviteLinkH]);
+ $inviteDetails = $this->il10->t('Invite code: %1$s Encoded invite: %2$s ', [$tokenSenderH, $encodedH]);
+ $messageHeading = $this->il10->t('Your message:');
+ $messageSection = trim($message) === '' ? '' : " $messageHeading $messageH ";
+
+ $htmlBody = "$header$messageSection$inviteLinkNote$inviteDetails";
+ $email->setHtmlBody($htmlBody);
+
+ $plainHeader = $this->il10->t('This is a copy of the invite you sent to %1$s.', [$recipientAddress]);
+ $plainInviteLine = $this->il10->t('Invite link: %1$s', [$inviteLink]);
+ $plainInviteCode = $this->il10->t('Invite code: %1$s', ["$token@$senderProvider"]);
+ $plainEncoded = $this->il10->t('Encoded invite: %1$s', [$encoded]);
+ $plainMessageSection = trim($message) === '' ? '' : "\n$messageHeading\n$message\n";
+ $plainBody = "$plainHeader\n$plainMessageSection\n$plainInviteLine\n$plainInviteCode\n$plainEncoded\n";
+ $email->setPlainBody($plainBody);
+
+ $this->mailer->send($email);
+ } catch (Exception $e) {
+ $this->logger->warning('Could not send CC email to sender: ' . $e->getMessage(), ['app' => Application::APP_ID]);
+ }
+ }
+
+ /**
+ * Persist an OCM invite bool admin setting. Admin-only by default since the
+ * method is not marked with NoAdminRequired.
+ *
+ * @param string $key one of FederatedInvitesService::OCM_INVITES_BOOL_KEYS
+ * @param bool $value the new value
+ * @return JSONResponse empty body with the appropriate HTTP status
+ */
+ #[FrontpageRoute(verb: 'PUT', url: '/ocm/admin/settings/{key}')]
+ public function setOcmInviteBoolSetting(string $key, bool $value): JSONResponse {
+ if (!$this->federatedInvitesService->setOcmInviteBoolSetting($key, $value)) {
+ return new JSONResponse(['message' => 'Unknown setting key'], Http::STATUS_FORBIDDEN);
+ }
+ return new JSONResponse([], Http::STATUS_OK);
+ }
+
+ /**
+ * Validate a recipient email address against the configured mailer.
+ *
+ * @return JSONResponse|null Error response on invalid input, null when valid.
+ */
+ private function validateEmail(string $address): ?JSONResponse {
+ if (!$this->mailer->validateMailAddress($address)) {
+ $this->logger->debug("Invalid recipient email address '$address'", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => $this->il10->t('Recipient email address is invalid.')], Http::STATUS_UNPROCESSABLE_ENTITY);
+ }
+ return null;
+ }
+
+ /**
+ * @param string $token the invite token
+ * @param string $senderProvider this provider
+ * @param string $address the recipient email address to send the invitation to
+ * @param string $message the optional message to send with the invitation
+ * @return JSONResponse
+ */
+ private function sendEmail(string $token, string $senderProvider, string $address, string $message): JSONResponse {
+ $validationError = $this->validateEmail($address);
+ if ($validationError !== null) {
+ return $validationError;
+ }
+ /** @var IMessage */
+ $email = $this->mailer->createMessage();
+ $email->setTo([$address]);
+
+ $instanceName = $this->defaults->getName();
+ $initiatorDisplayName = $this->userSession->getUser()->getDisplayName();
+ $senderName = $this->il10->t(
+ '%1$s via %2$s',
+ [
+ $initiatorDisplayName,
+ $instanceName
+ ]
+ );
+ $email->setFrom([Util::getDefaultEmailAddress($instanceName) => $senderName]);
+ $subject = $this->il10->t('%1$s invites you to share contacts using your cloud account', [$initiatorDisplayName]);
+ $email->setSubject($subject);
+
+ $wayfEndpoint = $this->wayfProvider->getWayfEndpoint();
+ if (empty($wayfEndpoint)) {
+ $this->logger->error('Invalid WAYF endpoint (null).', ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => 'Could not send invite.'], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ $inviteLink = $this->buildWayfInviteLink($wayfEndpoint, $token, $senderProvider);
+ $encoded = base64_encode("$token@$senderProvider");
+
+ $initiatorDisplayNameH = htmlspecialchars($initiatorDisplayName, ENT_QUOTES, 'UTF-8');
+ $inviteLinkH = htmlspecialchars($inviteLink, ENT_QUOTES, 'UTF-8');
+ $tokenSenderH = htmlspecialchars("$token@$senderProvider", ENT_QUOTES, 'UTF-8');
+ $encodedH = htmlspecialchars($encoded, ENT_QUOTES, 'UTF-8');
+ $messageH = nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), false);
+
+ $header = $this->il10->t('Hi there,
%1$s invites you to exchange cloud accounts and contact information. This will allow you to share data with each other. ', [$initiatorDisplayNameH]);
+ $messageSection = trim($message) === '' ? '' : " --- $messageH --- ";
+ $inviteLinkNote = $this->il10->t(' To accept this invite, click the link below and sign in with your cloud provider:
%1$s ', [$inviteLinkH]);
+ $technicalDetails = $this->il10->t(' Technical details (for advanced setups): Invite code: %1$s Encoded invite: %2$s', [$tokenSenderH, $encodedH]);
+
+ $htmlBody = "$header$messageSection$inviteLinkNote$technicalDetails";
+ $email->setHtmlBody($htmlBody);
+
+ $plainHeader = $this->il10->t('Hi there,', []) . "\n\n" . $this->il10->t('%1$s invites you to share contact information using your cloud account.', [$initiatorDisplayName]);
+ $plainMessageSection = trim($message) === '' ? '' : "\n---\n$message\n---\n";
+ $plainInviteLinkNote = $this->il10->t('To accept this invite, click the link below and sign in with your cloud provider:', []) . "\n\n" . $inviteLink;
+ $plainTechnical = $this->il10->t('Technical details (for advanced setups):', []) . "\n"
+ . $this->il10->t('Invite code: %1$s', ["$token@$senderProvider"]) . "\n"
+ . $this->il10->t('Encoded invite: %1$s', [$encoded]);
+ $plainBody = "$plainHeader\n$plainMessageSection\n$plainInviteLinkNote\n\n$plainTechnical\n";
+ $email->setPlainBody($plainBody);
+
+ try {
+ /** @var string[] $failedRecipients */
+ $failedRecipients = $this->mailer->send($email);
+ } catch (\Throwable $e) {
+ $this->logger->error("Mail transport failure while sending invite to '$address': " . $e->getMessage(), [
+ 'app' => Application::APP_ID,
+ 'exception' => $e,
+ ]);
+ return new JSONResponse(['message' => "Could not send invite to '$address'"], Http::STATUS_BAD_GATEWAY);
+ }
+
+ if (!empty($failedRecipients)) {
+ $this->logger->error("Could not send invite to '$address'", ['app' => Application::APP_ID]);
+ return new JSONResponse(['message' => "Could not send invite to '$address'"], Http::STATUS_BAD_GATEWAY);
+ }
+
+ return new JSONResponse([], Http::STATUS_OK);
+ }
+
+ private function normalizeProviderBase(string $provider): ?string {
+ $candidate = trim($provider);
+ if ($candidate === '') {
+ return null;
+ }
+ if (!preg_match('#^https?://#i', $candidate)) {
+ $candidate = 'https://' . $candidate;
+ }
+
+ $parts = parse_url($candidate);
+ if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) {
+ return null;
+ }
+
+ $scheme = strtolower((string)$parts['scheme']);
+ if (!in_array($scheme, ['http', 'https'], true)) {
+ return null;
+ }
+
+ $host = strtolower((string)$parts['host']);
+ if ($host === '') {
+ return null;
+ }
+ if (!$this->federatedInvitesService->isSsrfGuardDisabled() && $this->isBlockedDiscoveryHost($host)) {
+ return null;
+ }
+
+ $port = '';
+ if (isset($parts['port'])) {
+ $portNumber = (int)$parts['port'];
+ if ($portNumber < 1 || $portNumber > 65535) {
+ return null;
+ }
+ $port = ':' . $portNumber;
+ }
+
+ $path = '';
+ if (isset($parts['path']) && is_string($parts['path']) && $parts['path'] !== '') {
+ $path = '/' . trim($parts['path'], '/');
+ $path = rtrim($path, '/');
+ }
+
+ return $scheme . '://' . $host . $port . $path;
+ }
+
+ private function isBlockedDiscoveryHost(string $host): bool {
+ $normalizedHost = strtolower(trim($host));
+ if ($normalizedHost === 'localhost' || str_ends_with($normalizedHost, '.localhost')) {
+ return true;
+ }
+
+ if (filter_var($normalizedHost, FILTER_VALIDATE_IP) === false) {
+ return false;
+ }
+
+ return filter_var(
+ $normalizedHost,
+ FILTER_VALIDATE_IP,
+ FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
+ ) === false;
+ }
+
+ private function buildInviteAcceptDialogAbsolute(string $base, string $dialog): ?string {
+ $trimmedDialog = trim($dialog);
+ if ($trimmedDialog === '') {
+ return null;
+ }
+
+ $baseParts = parse_url($base);
+ if (!is_array($baseParts) || !isset($baseParts['scheme'], $baseParts['host'])) {
+ return null;
+ }
+
+ if (preg_match('#^https?://#i', $trimmedDialog)) {
+ $dialogUrl = $this->normalizeProviderBase($trimmedDialog);
+ if ($dialogUrl === null) {
+ return null;
+ }
+ $dialogParts = parse_url($dialogUrl);
+ if (!is_array($dialogParts) || !isset($dialogParts['host'])) {
+ return null;
+ }
+
+ $basePort = $baseParts['port'] ?? null;
+ $dialogPort = $dialogParts['port'] ?? null;
+ if (strtolower((string)$dialogParts['host']) !== strtolower((string)$baseParts['host']) || $basePort !== $dialogPort) {
+ return null;
+ }
+
+ return $dialogUrl;
+ }
+
+ $origin = $baseParts['scheme'] . '://' . $baseParts['host'];
+ if (isset($baseParts['port'])) {
+ $origin .= ':' . $baseParts['port'];
+ }
+
+ if (str_starts_with($trimmedDialog, '/')) {
+ return $origin . $trimmedDialog;
+ }
+
+ return rtrim($base, '/') . '/' . ltrim($trimmedDialog, '/');
+ }
+
+ private function buildWayfInviteLink(string $wayfEndpoint, string $token, string $senderProvider): string {
+ $separator = str_contains($wayfEndpoint, '?') ? '&' : '?';
+ $query = http_build_query([
+ 'token' => $token,
+ 'providerDomain' => $senderProvider,
+ ], '', '&', PHP_QUERY_RFC3986);
+ return $wayfEndpoint . $separator . $query;
+ }
+
+ private function requireOcmInvitesEnabled(): ?JSONResponse {
+ if ($this->federatedInvitesService->isOcmInvitesEnabled()) {
+ return null;
+ }
+
+ return new JSONResponse([
+ 'code' => 'ocm_invites_disabled',
+ 'message' => $this->il10->t('OCM invites are disabled.'),
+ ], Http::STATUS_FORBIDDEN);
+ }
+
+ private function cleanupSupersededInvitesForRecipient(string $uid, string $email): int {
+ if ($email === '') {
+ return 0;
+ }
+
+ try {
+ return $this->federatedInviteMapper->deleteSupersededInvitesForRecipientEmail(
+ $uid,
+ $email,
+ $this->timeFactory->now()->getTimestamp(),
+ );
+ } catch (Exception $e) {
+ $this->logger->warning('Could not clean up superseded invites for recipient email.', [
+ 'app' => Application::APP_ID,
+ 'userId' => $uid,
+ 'email' => $email,
+ 'exception' => $e,
+ ]);
+ return 0;
+ }
+ }
+
+ private function isInviteAccepted(FederatedInvite $invite): bool {
+ return $invite->isAccepted() === true || $invite->getAcceptedAt() !== null;
+ }
+
+ private function isInviteExpired(FederatedInvite $invite): bool {
+ $expiredAt = $invite->getExpiredAt();
+ return $expiredAt !== null && $expiredAt <= $this->timeFactory->now()->getTimestamp();
+ }
+
+ private function isDuplicateConstraintException(Throwable $e): bool {
+ $message = strtolower($e->getMessage());
+ return str_contains($message, 'duplicate')
+ || str_contains($message, 'unique')
+ || str_contains($message, 'constraint');
+ }
+}
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index 6d7ce77fc0..f6fe993ee6 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -9,6 +9,7 @@
use OC\App\CompareVersion;
use OCA\Contacts\AppInfo\Application;
+use OCA\Contacts\Service\FederatedInvitesService;
use OCA\Contacts\Service\GroupSharingService;
use OCA\Contacts\Service\SocialApiService;
use OCP\App\IAppManager;
@@ -25,6 +26,7 @@ class PageController extends Controller {
public function __construct(
IRequest $request,
+ private FederatedInvitesService $federatedInvitesService,
private IConfig $config,
private IInitialState $initialState,
private IFactory $languageFactory,
@@ -67,6 +69,8 @@ public function index(): TemplateResponse {
$isTalkEnabled = $this->appManager->isEnabledForUser('spreed') === true;
$isTalkVersionCompatible = $this->compareVersion->isCompatible($talkVersion ? $talkVersion : '0.0.0', 2);
+ $isOcmInvitesEnabled = $this->federatedInvitesService->isOcmInvitesEnabled();
+ $ocmInvitesConfig = $this->federatedInvitesService->getOcmInvitesConfig();
$this->initialState->provideInitialState('isGroupSharingEnabled', $isGroupSharingEnabled);
$this->initialState->provideInitialState('locales', $locales);
@@ -77,6 +81,8 @@ public function index(): TemplateResponse {
$this->initialState->provideInitialState('isContactsInteractionEnabled', $isContactsInteractionEnabled);
$this->initialState->provideInitialState('isCirclesEnabled', $isCirclesEnabled && $isCircleVersionCompatible);
$this->initialState->provideInitialState('isTalkEnabled', $isTalkEnabled && $isTalkVersionCompatible);
+ $this->initialState->provideInitialState('isOcmInvitesEnabled', $isOcmInvitesEnabled);
+ $this->initialState->provideInitialState('ocmInvitesConfig', $ocmInvitesConfig);
Util::addStyle(Application::APP_ID, 'contacts-main');
Util::addScript(Application::APP_ID, 'contacts-main');
diff --git a/lib/Cron/UpdateOcmProviders.php b/lib/Cron/UpdateOcmProviders.php
new file mode 100644
index 0000000000..d20865450c
--- /dev/null
+++ b/lib/Cron/UpdateOcmProviders.php
@@ -0,0 +1,37 @@
+final setInterval($this->expire_time);
+ }
+
+ #[\Override]
+ protected function run($argument) {
+ $data = $this->wayfProvider->getMeshProviders();
+ $data['expires'] = time() + $this->expire_time;
+ $this->appConfig->setValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, $data, true);
+ }
+}
diff --git a/lib/Db/FederatedInvite.php b/lib/Db/FederatedInvite.php
new file mode 100644
index 0000000000..ad757ec7cf
--- /dev/null
+++ b/lib/Db/FederatedInvite.php
@@ -0,0 +1,78 @@
+addType('accepted', Types::BOOLEAN);
+ $this->addType('acceptedAt', Types::BIGINT);
+ $this->addType('createdAt', Types::BIGINT);
+ $this->addType('expiredAt', Types::BIGINT);
+ $this->addType('recipientEmail', Types::STRING);
+ $this->addType('recipientName', Types::STRING);
+ $this->addType('recipientProvider', Types::STRING);
+ $this->addType('recipientUserId', Types::STRING);
+ $this->addType('token', Types::STRING);
+ $this->addType('userId', Types::STRING);
+ }
+
+ public function jsonSerialize(): array {
+ return [
+ 'accepted' => $this->accepted,
+ 'acceptedAt' => $this->acceptedAt,
+ 'createdAt' => $this->createdAt,
+ 'expiredAt' => $this->expiredAt,
+ 'recipientEmail' => $this->recipientEmail,
+ 'recipientName' => $this->recipientName,
+ 'recipientProvider' => $this->recipientProvider,
+ 'recipientUserId' => $this->recipientUserId,
+ 'token' => $this->token,
+ 'userId' => $this->userId,
+ ];
+ }
+
+}
diff --git a/lib/Db/FederatedInviteMapper.php b/lib/Db/FederatedInviteMapper.php
new file mode 100644
index 0000000000..39e02025a1
--- /dev/null
+++ b/lib/Db/FederatedInviteMapper.php
@@ -0,0 +1,183 @@
+
+ */
+class FederatedInviteMapper extends QBMapper {
+ public const TABLE_NAME = 'federated_invites';
+
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, self::TABLE_NAME);
+ }
+
+ /**
+ * Returns the federated invite with the specified token
+ *
+ * @return FederatedInvite
+ */
+ public function findByToken(string $token): FederatedInvite {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from(self::TABLE_NAME)
+ ->where($qb->expr()->eq('token', $qb->createNamedParameter($token)));
+ return $this->findEntity($qb);
+ }
+
+ /**
+ * Returns all open federated invites for the user with the specified user id
+ *
+ * @return list
+ */
+ public function findOpenInvitesByUid(string $userId, ?int $now = null): array {
+ $timestamp = $now ?? time();
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from(self::TABLE_NAME)
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->andWhere($qb->expr()->isNull('accepted_at'))
+ ->andWhere(
+ $qb->expr()->orX(
+ $qb->expr()->isNull('expired_at'),
+ $qb->expr()->gt('expired_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)),
+ ),
+ );
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * Returns all open federated invites for the user with the specified user id and for the specified recipient email
+ *
+ * @return list
+ */
+ public function findOpenInvitesByRecipientEmail(string $userId, string $email, ?int $now = null): array {
+ $timestamp = $now ?? time();
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from(self::TABLE_NAME)
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('recipient_email', $qb->createNamedParameter($email)))
+ ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->andWhere($qb->expr()->isNull('accepted_at'))
+ ->andWhere(
+ $qb->expr()->orX(
+ $qb->expr()->isNull('expired_at'),
+ $qb->expr()->gt('expired_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)),
+ ),
+ );
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * Returns the federated invite with the specified token for the user with the specified user id.
+ *
+ * @return FederatedInvite the matching invite
+ */
+ public function findInviteByTokenAndUid(string $token, string $userId):FederatedInvite {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from(self::TABLE_NAME)
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR)));
+ return $this->findEntity($qb);
+ }
+
+ /**
+ * Atomically claims an unclaimed (recipient_email IS NULL) and unaccepted
+ * invite for the given email and refreshes its lifetime.
+ *
+ * Returns true when exactly one row was affected, meaning the caller now
+ * owns the (token, recipient_email) pair. Returns false when the row no
+ * longer matches the precondition (a concurrent attach already claimed
+ * the invite, the invite was accepted, or the row vanished).
+ */
+ public function claimInviteForEmail(
+ string $token,
+ string $userId,
+ string $email,
+ int $createdAt,
+ int $expiredAt,
+ ): bool {
+ $qb = $this->db->getQueryBuilder();
+ $qb->update(self::TABLE_NAME)
+ ->set('recipient_email', $qb->createNamedParameter($email))
+ ->set('created_at', $qb->createNamedParameter($createdAt, IQueryBuilder::PARAM_INT))
+ ->set('expired_at', $qb->createNamedParameter($expiredAt, IQueryBuilder::PARAM_INT))
+ ->where($qb->expr()->eq('token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR)))
+ ->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->isNull('recipient_email'))
+ ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->andWhere($qb->expr()->isNull('accepted_at'));
+ return $qb->executeStatement() === 1;
+ }
+
+ /**
+ * Best-effort revert of a previous claim made by claimInviteForEmail().
+ * Scoped to the same sender (user_id) and only takes effect when the row
+ * still has the email we set and is still unaccepted, so the revert
+ * cannot undo a successful accept and cannot run if a concurrent attach
+ * changed recipient_email between the claim and the revert.
+ *
+ * Returns true when the revert took effect (exactly one row updated).
+ */
+ public function revertInviteEmail(
+ string $token,
+ string $userId,
+ string $email,
+ int $previousCreatedAt,
+ ?int $previousExpiredAt,
+ ): bool {
+ $qb = $this->db->getQueryBuilder();
+ $expiredParam = $previousExpiredAt === null
+ ? $qb->createNamedParameter(null, IQueryBuilder::PARAM_NULL)
+ : $qb->createNamedParameter($previousExpiredAt, IQueryBuilder::PARAM_INT);
+ $qb->update(self::TABLE_NAME)
+ ->set('recipient_email', $qb->createNamedParameter(null, IQueryBuilder::PARAM_NULL))
+ ->set('created_at', $qb->createNamedParameter($previousCreatedAt, IQueryBuilder::PARAM_INT))
+ ->set('expired_at', $expiredParam)
+ ->where($qb->expr()->eq('token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR)))
+ ->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('recipient_email', $qb->createNamedParameter($email)))
+ ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->andWhere($qb->expr()->isNull('accepted_at'));
+ return $qb->executeStatement() === 1;
+ }
+
+ /**
+ * Deletes invites that can no longer be acted on but would still block a
+ * fresh invite for the same recipient email. This covers expired rows and
+ * defensive cleanup for rows that already have an accepted_at timestamp.
+ */
+ public function deleteSupersededInvitesForRecipientEmail(string $userId, string $email, ?int $now = null): int {
+ $timestamp = $now ?? time();
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete(self::TABLE_NAME)
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('recipient_email', $qb->createNamedParameter($email)))
+ ->andWhere(
+ $qb->expr()->orX(
+ $qb->expr()->isNotNull('accepted_at'),
+ $qb->expr()->andX(
+ $qb->expr()->eq('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)),
+ $qb->expr()->isNotNull('expired_at'),
+ $qb->expr()->lte('expired_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)),
+ ),
+ ),
+ );
+ return $qb->executeStatement();
+ }
+
+}
diff --git a/lib/Exception/ContactExistsException.php b/lib/Exception/ContactExistsException.php
new file mode 100644
index 0000000000..98b7fd0ad9
--- /dev/null
+++ b/lib/Exception/ContactExistsException.php
@@ -0,0 +1,15 @@
+
+ */
+class FederatedInviteAcceptedListener implements IEventListener {
+
+ public function __construct(
+ private FederatedInvitesService $federatedInvitesService,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * Handles the OCMEndpointRequestEvent that is dispatched by the
+ * OCMRequestController as response to an OCM request. This handler manages
+ * the invite-accepted capability.
+ */
+ #[\Override]
+ public function handle(Event $event): void {
+ if (!($event instanceof OCMEndpointRequestEvent)
+ || $event->getRequestedCapability() !== 'invite-accepted') {
+ return;
+ }
+
+ $payload = $event->getPayload();
+ if (!$this->isValidInviteAcceptedPayload($payload)) {
+ $this->logger->error('Could not accept invite, user data is incomplete.', [
+ 'app' => Application::APP_ID,
+ 'payloadKeys' => array_keys($payload),
+ ]);
+ $event->setResponse(new JSONResponse([
+ 'message' => 'Could not accept invite, user data is incomplete.',
+ ], Http::STATUS_NOT_FOUND));
+ return;
+ }
+
+ $event->setResponse($this->federatedInvitesService->inviteAccepted(
+ $payload['recipientProvider'],
+ $payload['token'],
+ $payload['userID'],
+ $payload['email'],
+ $payload['name'],
+ ));
+ }
+
+ /**
+ * The accepted-invite callback requires all documented OCM string fields to
+ * be present and non-empty.
+ *
+ * @param array $payload
+ */
+ private function isValidInviteAcceptedPayload(array $payload): bool {
+ foreach (['recipientProvider', 'token', 'userID', 'email', 'name'] as $key) {
+ if (!array_key_exists($key, $payload) || !is_string($payload[$key])) {
+ return false;
+ }
+ }
+
+ return trim($payload['recipientProvider']) !== ''
+ && trim($payload['token']) !== ''
+ && trim($payload['userID']) !== ''
+ && trim($payload['email']) !== ''
+ && trim($payload['name']) !== '';
+ }
+}
diff --git a/lib/Listener/OcmDiscoveryListener.php b/lib/Listener/OcmDiscoveryListener.php
new file mode 100644
index 0000000000..722ea518a8
--- /dev/null
+++ b/lib/Listener/OcmDiscoveryListener.php
@@ -0,0 +1,69 @@
+ */
+class OcmDiscoveryListener implements IEventListener {
+ public function __construct(
+ private IAppConfig $appConfig,
+ private IURLGenerator $urlGenerator,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * Register the invite capability and dialog route on local OCM discovery.
+ *
+ * @param Event $event an event of type LocalOCMDiscoveryEvent or ResourceTypeRegisterEvent
+ * @return void
+ */
+ #[\Override]
+ public function handle(Event $event): void {
+ if (!$this->isOcmDiscoveryEvent($event)) {
+ return;
+ }
+
+ if (!$this->appConfig->getValueBool(Application::APP_ID, ContactsConfigLexicon::OCM_INVITES_ENABLED)) {
+ return;
+ }
+
+ if ($event instanceof LocalOCMDiscoveryEvent) {
+ $event->addCapability('invite-accepted');
+
+ try {
+ $event->getProvider()->setInviteAcceptDialog(
+ $this->urlGenerator->linkToRouteAbsolute(FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE_NAME),
+ );
+ } catch (Throwable $e) {
+ $this->logger->warning('OCM invites are enabled but invite accept dialog route cannot be resolved', [
+ 'app' => Application::APP_ID,
+ 'route' => FederatedInvitesService::OCM_INVITE_ACCEPT_DIALOG_ROUTE_NAME,
+ 'exception' => $e,
+ ]);
+ }
+ }
+ }
+
+ private function isOcmDiscoveryEvent(Event $event): bool {
+ return $event instanceof LocalOCMDiscoveryEvent
+ || $event instanceof ResourceTypeRegisterEvent;
+ }
+}
diff --git a/lib/Migration/Version8004Date20260130131217.php b/lib/Migration/Version8004Date20260130131217.php
new file mode 100644
index 0000000000..709e78761a
--- /dev/null
+++ b/lib/Migration/Version8004Date20260130131217.php
@@ -0,0 +1,92 @@
+hasTable($table_name)) {
+ $table = $schema->createTable($table_name);
+ $table->addColumn('id', Types::BIGINT, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'length' => 11,
+ 'unsigned' => true,
+ ]);
+ $table->addColumn('user_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 64,
+
+ ]);
+ // https://saturncloud.io/blog/what-is-the-maximum-length-of-a-url-in-different-browsers/#maximum-url-length-in-different-browsers
+ // We use the least common denominator, the minimum length supported by browsers
+ $table->addColumn('recipient_provider', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 2083,
+ ]);
+ $table->addColumn('recipient_user_id', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 1024,
+ ]);
+ $table->addColumn('recipient_name', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 1024,
+ ]);
+ // https://www.directedignorance.com/blog/maximum-length-of-email-address
+ $table->addColumn('recipient_email', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 320,
+ ]);
+ $table->addColumn('token', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 60,
+ ]);
+ $table->addColumn('accepted', Types::BOOLEAN, [
+ 'notnull' => false,
+ 'default' => false
+ ]);
+ $table->addColumn('created_at', Types::BIGINT, [
+ 'notnull' => true,
+ ]);
+
+ $table->addColumn('expired_at', Types::BIGINT, [
+ 'notnull' => false,
+ ]);
+
+ $table->addColumn('accepted_at', Types::BIGINT, [
+ 'notnull' => false,
+ ]);
+
+ $table->addUniqueConstraint(['token']);
+ $table->setPrimaryKey(['id']);
+ return $schema;
+ }
+
+ return null;
+ }
+}
diff --git a/lib/Migration/Version8005Date20260418120000.php b/lib/Migration/Version8005Date20260418120000.php
new file mode 100644
index 0000000000..18a6577ca5
--- /dev/null
+++ b/lib/Migration/Version8005Date20260418120000.php
@@ -0,0 +1,127 @@
+connection->getQueryBuilder();
+ $qb->update('federated_invites')
+ ->set('accepted', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
+ ->where($qb->expr()->isNull('accepted'));
+ $qb->executeStatement();
+ }
+
+ #[\Override]
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ if (!$schema->hasTable('federated_invites')) {
+ return null;
+ }
+
+ $table = $schema->getTable('federated_invites');
+ if (!$table->hasColumn('accepted')) {
+ return null;
+ }
+
+ $column = $table->getColumn('accepted');
+ if ($column->getNotnull() === true) {
+ return null;
+ }
+
+ $column->setNotnull(true);
+ $column->setDefault(false);
+
+ return $schema;
+ }
+
+ #[\Override]
+ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ $this->createPartialUniqueIndex($output);
+ }
+
+ private function createPartialUniqueIndex(IOutput $output): void {
+ $provider = $this->connection->getDatabaseProvider();
+ if ($provider !== IDBConnection::PLATFORM_POSTGRES && $provider !== IDBConnection::PLATFORM_SQLITE) {
+ $output->info(sprintf(
+ 'Skipping partial unique index %s on %s; application-level guard remains in effect.',
+ self::INDEX_NAME,
+ $provider,
+ ));
+ $this->logger->info(
+ 'Skipped partial unique index for federated_invites on database provider {provider}.',
+ ['app' => 'contacts', 'provider' => $provider],
+ );
+ return;
+ }
+
+ $predicate = $provider === IDBConnection::PLATFORM_POSTGRES
+ ? 'recipient_email IS NOT NULL AND accepted = false'
+ : 'recipient_email IS NOT NULL AND accepted = 0';
+
+ $sql = sprintf(
+ 'CREATE UNIQUE INDEX IF NOT EXISTS %s ON %sfederated_invites (user_id, recipient_email) WHERE %s',
+ self::INDEX_NAME,
+ '*PREFIX*',
+ $predicate,
+ );
+
+ try {
+ $this->connection->executeStatement($sql);
+ } catch (\Throwable $e) {
+ $this->logger->warning(
+ 'Failed to create partial unique index for federated_invites: {message}',
+ ['app' => 'contacts', 'message' => $e->getMessage(), 'exception' => $e],
+ );
+ throw new \RuntimeException('Could not create required open-invite uniqueness index.', 0, $e);
+ }
+ }
+
+}
diff --git a/lib/Service/FederatedInvitesService.php b/lib/Service/FederatedInvitesService.php
new file mode 100644
index 0000000000..bb4a12503c
--- /dev/null
+++ b/lib/Service/FederatedInvitesService.php
@@ -0,0 +1,257 @@
+appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_ENABLED);
+ }
+
+ public function isOptionalMailEnabled(): bool {
+ return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_OPTIONAL_MAIL);
+ }
+
+ public function isCcSenderEnabled(): bool {
+ return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_CC_SENDER);
+ }
+
+ public function isEncodedCopyButtonEnabled(): bool {
+ return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_ENCODED_COPY_BUTTON);
+ }
+
+ public function isSsrfGuardDisabled(): bool {
+ return $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::OCM_INVITES_DISABLE_SSRF_GUARD);
+ }
+
+ /**
+ * The set of admin-toggleable OCM bool keys. Used to gate writes from the
+ * admin settings page so callers cannot persist arbitrary keys.
+ */
+ public const OCM_INVITES_BOOL_KEYS = [
+ ConfigLexicon::OCM_INVITES_OPTIONAL_MAIL,
+ ConfigLexicon::OCM_INVITES_CC_SENDER,
+ ConfigLexicon::OCM_INVITES_ENCODED_COPY_BUTTON,
+ ConfigLexicon::OCM_INVITES_DISABLE_SSRF_GUARD,
+ ];
+
+ /**
+ * Persist an OCM admin bool toggle. Returns true when the key is allowed.
+ */
+ public function setOcmInviteBoolSetting(string $key, bool $value): bool {
+ if (!in_array($key, self::OCM_INVITES_BOOL_KEYS, true)) {
+ return false;
+ }
+ $this->appConfig->setValueBool(Application::APP_ID, $key, $value);
+ return true;
+ }
+
+ /**
+ * Returns all OCM invites config flags for frontend consumption
+ */
+ public function getOcmInvitesConfig(): array {
+ return [
+ 'optionalMail' => $this->isOptionalMailEnabled(),
+ 'ccSender' => $this->isCcSenderEnabled(),
+ 'encodedCopyButton' => $this->isEncodedCopyButtonEnabled(),
+ ];
+ }
+
+ /**
+ * Returns the provider's server FQDN.
+ * @return string the FQDN
+ */
+ public function getProviderFQDN(): string {
+ $serverUrl = $this->urlGenerator->getAbsoluteURL('/');
+ $parts = parse_url($serverUrl);
+ if (!is_array($parts) || !isset($parts['host']) || !is_string($parts['host'])) {
+ return '';
+ }
+ return $parts['host'];
+ }
+
+ /**
+ * Returns the expiration date.
+ * @param int $creationDate
+ * @return int the expiration date
+ */
+ public function getInviteExpirationDate(int $creationDate): int {
+ return $creationDate + self::INVITE_EXPIRATION_PERIOD_SECONDS;
+ }
+
+ /**
+ * Creates a new contact and adds it to the address book of the user with the specified userId or,
+ * if null, the current logged-in user.
+ *
+ * @param string cloudId
+ * @param string email
+ * @param string name
+ * @param ?string userId id of the user for which to create the new contact.
+ * If null, this is the current logged-in user.
+ *
+ * @return string the ref of the new contact in the form
+ * 'contactURI~addressBookUri'
+ * @throws ContactExistsException
+ */
+ public function createNewContact(string $cloudId, string $email, string $name, ?string $userId): ?string {
+ $localUserId = $userId ? $userId : $this->userSession->getUser()->getUID();
+ $newContact = $this->socialApiService->createContact(
+ $cloudId,
+ $email,
+ $name,
+ $localUserId,
+ );
+ if (!isset($newContact)) {
+ $this->logger->error('Error creating contact for user {userId} with cloud id {cloudId}.', [
+ 'app' => Application::APP_ID,
+ 'userId' => $localUserId,
+ 'cloudId' => $cloudId,
+ ]);
+ return null;
+ }
+ $this->logger->info('Created new contact with UID: ' . $newContact['UID'] . ' for user with UID: ' . $localUserId, ['app' => Application::APP_ID]);
+ $addressBookUri = CardDavBackend::PERSONAL_ADDRESSBOOK_URI;
+ if (isset($newContact['ADDRESSBOOK_URI']) && is_string($newContact['ADDRESSBOOK_URI']) && $newContact['ADDRESSBOOK_URI'] !== '') {
+ $addressBookUri = $newContact['ADDRESSBOOK_URI'];
+ }
+ $contactRef = $newContact['UID'] . '~' . $addressBookUri;
+ return $contactRef;
+ }
+
+ /**
+ * This is the invite-accepted capability implementation.
+ */
+ public function inviteAccepted(string $recipientProvider, string $token, string $userID, string $email, string $name): JSONResponse {
+ $this->logger->debug('Processing share invitation for ' . $userID . ' with token ' . $token . ' and email ' . $email . ' and name ' . $name);
+
+ $updated = $this->timeFactory->getTime();
+
+ if ($token === '') {
+ $response = new JSONResponse(['message' => 'Invalid or non existing token', 'error' => true], Http::STATUS_BAD_REQUEST);
+ $response->throttle();
+ return $response;
+ }
+
+ if (trim($recipientProvider) === '' || trim($userID) === '' || trim($email) === '' || trim($name) === '') {
+ return new JSONResponse(['message' => 'Could not accept invite, user data is incomplete.', 'error' => true], Http::STATUS_BAD_REQUEST);
+ }
+
+ try {
+ $invitation = $this->federatedInviteMapper->findByToken($token);
+ } catch (DoesNotExistException) {
+ $response = ['message' => 'Invalid or non existing token', 'error' => true];
+ $status = Http::STATUS_BAD_REQUEST;
+ $response = new JSONResponse($response, $status);
+ $response->throttle();
+ return $response;
+ }
+
+ if ($invitation->isAccepted() === true) {
+ $response = ['message' => 'Invite already accepted', 'error' => true];
+ $status = Http::STATUS_CONFLICT;
+ return new JSONResponse($response, $status);
+ }
+
+ if ($invitation->getExpiredAt() !== null && $updated > $invitation->getExpiredAt()) {
+ $response = ['message' => 'Invitation expired', 'error' => true];
+ $status = Http::STATUS_BAD_REQUEST;
+ return new JSONResponse($response, $status);
+ }
+ // Note that there is no user session; local user is the sender of the invite
+ $localUser = $this->userManager->get($invitation->getUserId());
+ if ($localUser === null) {
+ $response = ['message' => 'Invalid or non existing token', 'error' => true];
+ $status = Http::STATUS_BAD_REQUEST;
+ $response = new JSONResponse($response, $status);
+ $response->throttle();
+ return $response;
+ }
+
+ $sharedFromEmail = $localUser->getEMailAddress();
+ if ($sharedFromEmail === null) {
+ $response = ['message' => 'Invalid or non existing token', 'error' => true];
+ $status = Http::STATUS_BAD_REQUEST;
+ $response = new JSONResponse($response, $status);
+ $response->throttle();
+ return $response;
+ }
+ $sharedFromDisplayName = $localUser->getDisplayName();
+
+ $response = ['userID' => $localUser->getUID(), 'email' => $sharedFromEmail, 'name' => $sharedFromDisplayName];
+ $status = Http::STATUS_OK;
+
+ $cloudId = $userID . '@' . $this->addressHandler->removeProtocolFromUrl($recipientProvider);
+ try {
+ $contactRef = $this->createNewContact(
+ $cloudId,
+ $email,
+ $name,
+ $localUser->getUID()
+ );
+ if ($contactRef === null) {
+ $this->logger->error('Could not create sender-side contact after invite acceptance.', [
+ 'app' => Application::APP_ID,
+ 'userId' => $localUser->getUID(),
+ 'cloudId' => $cloudId,
+ 'token' => $token,
+ ]);
+ return new JSONResponse([
+ 'message' => 'Could not create sender-side contact after invite acceptance.',
+ 'error' => true,
+ ], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+ } catch (ContactExistsException $e) {
+ // A duplicate sender-side contact should not block invite acceptance.
+ $this->logger->info("Contact with cloud id $cloudId already exists. ");
+ }
+
+ $invitation->setAccepted(true);
+ $invitation->setRecipientEmail($email);
+ $invitation->setRecipientName($name);
+ $invitation->setRecipientProvider($recipientProvider);
+ $invitation->setRecipientUserId($userID);
+ $invitation->setAcceptedAt($updated);
+ $invitation = $this->federatedInviteMapper->update($invitation);
+ return new JSONResponse($response, $status);
+ }
+}
diff --git a/lib/Service/SocialApiService.php b/lib/Service/SocialApiService.php
index ac9cbe6342..be9aee41d9 100644
--- a/lib/Service/SocialApiService.php
+++ b/lib/Service/SocialApiService.php
@@ -9,8 +9,11 @@
namespace OCA\Contacts\Service;
+use Exception;
use OCA\Contacts\AppInfo\Application;
+use OCA\Contacts\Exception\ContactExistsException;
use OCA\Contacts\Service\Social\CompositeSocialProvider;
+use OCA\DAV\CardDAV\CardDavBackend;
use OCA\DAV\CardDAV\ContactsManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
@@ -20,9 +23,11 @@
use OCP\Http\Client\IClientService;
use OCP\IAddressBook;
use OCP\IConfig;
+use OCP\ICreateContactFromString;
use OCP\IL10N;
use OCP\IURLGenerator;
use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
use function in_array;
class SocialApiService {
@@ -44,6 +49,7 @@ public function __construct(
private IURLGenerator $urlGen,
private ITimeFactory $timeFactory,
private ImageResizer $imageResizer,
+ private LoggerInterface $logger,
) {
$this->appName = Application::APP_ID;
}
@@ -94,30 +100,22 @@ protected function addPhoto(array &$contact, string $imageType, string $photo) {
/**
* Gets the addressbook of an addressbookId
*
- * @param string $addressBookId the identifier of the addressbook
+ * @param string $addressbookId the identifier of the addressbook
* @param IManager|null $manager optional a ContactManager to use
*
* @return IAddressBook|null the corresponding addressbook or null
*/
- protected function getAddressBook(string $addressBookId, ?IManager $manager = null) : ?IAddressBook {
+ protected function getAddressBook(string $addressbookId, ?IManager $manager = null) : ?IAddressBook {
$addressBook = null;
if ($manager === null) {
$manager = $this->manager;
}
$addressBooks = $manager->getUserAddressBooks();
foreach ($addressBooks as $ab) {
- if ($ab->getUri() === $addressBookId) {
+ if ($ab->getUri() === $addressbookId) {
$addressBook = $ab;
}
}
-
- $addressBookIsUpdatable = $addressBook !== null
- && ($addressBook->getPermissions() & Constants::PERMISSION_UPDATE);
-
- if (!$addressBookIsUpdatable) {
- return null;
- }
-
return $addressBook;
}
@@ -166,7 +164,11 @@ public function updateContact(string $addressbookId, string $contactId, ?string
$contact = $contacts[0];
if ($network) {
- $allConnectors = [$this->socialProvider->getSocialConnector($network)];
+ $connector = $this->socialProvider->getSocialConnector($network);
+ if ($connector === null) {
+ return new JSONResponse([], Http::STATUS_BAD_REQUEST);
+ }
+ $allConnectors = [$connector];
}
$connectors = array_filter($allConnectors, function ($connector) use ($contact) {
@@ -191,7 +193,10 @@ public function updateContact(string $addressbookId, string $contactId, ?string
try {
$httpResult = $this->clientService->newClient()->get($url);
$socialdata = $httpResult->getBody();
- $imageType = $httpResult->getHeader('content-type');
+ $imageTypeHeader = $httpResult->getHeader('content-type');
+ if (is_string($imageTypeHeader) && $imageTypeHeader !== '') {
+ $imageType = strtolower(trim(explode(';', $imageTypeHeader, 2)[0]));
+ }
if (isset($socialdata) && !empty($imageType)) {
break;
}
@@ -234,6 +239,130 @@ public function updateContact(string $addressbookId, string $contactId, ?string
return new JSONResponse([], Http::STATUS_OK);
}
+ /**
+ * Creates a contact and adds it to the address book of the local user with the specified userId,
+ * unless a contact with the specified cloudId already exists for that local user.
+ *
+ * @param {string} cloudId the cloud id of the contact
+ * @param {string} email the email of the contact
+ * @param {string} name the name of the contact
+ * @param {string} userId the uid of the local user
+ * @throws ContactExistsException
+ */
+ public function createContact(string $cloudId, string $email, string $name, string $userId): ?array {
+ try {
+ // Set up the contacts provider for the user with the specified uid
+ $cm = $this->serverContainer->get(ContactsManager::class);
+ $cm->setupContactsProvider($this->manager, $userId, $this->urlGen);
+
+ // if contact already exists we throw ContactExistsException
+ $searchResult = $this->manager->search($cloudId, ['CLOUD']);
+ if (count($searchResult) > 0) {
+ $this->logger->info('Contact with cloud id ' . $cloudId . ' already exists.', ['app' => Application::APP_ID]);
+ throw new ContactExistsException('Contact with cloud id ' . $cloudId . ' already exists.');
+ }
+
+ $addressBook = $this->pickAddressBookForContactCreation($this->manager->getUserAddressBooks());
+ if (!isset($addressBook)) {
+ $this->logger->error('No suitable address book found. Unable to add the new contact on invite accepted.', ['app' => Application::APP_ID]);
+ return null;
+ }
+
+ $newContact = $this->manager->createOrUpdate(
+ [
+ 'FN' => $name,
+ 'EMAIL' => $email,
+ 'CLOUD' => $cloudId,
+ ],
+ $addressBook->getKey()
+ );
+ $newContact['ADDRESSBOOK_URI'] = $addressBook->getUri();
+ return $newContact;
+ } catch (ContactExistsException $e) {
+ throw $e;
+ } catch (Exception $e) {
+ $this->logger->error('An exception occurred creating a new contact: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ }
+ return null;
+ }
+
+ /**
+ * Creates a federated contact (no thrown exceptions; null on duplicate or
+ * when no suitable writable address book exists).
+ *
+ * Used by the FederatedInviteAcceptedListener on the inviter side, where
+ * there is no user session and the inviter UID must be passed explicitly.
+ *
+ * @param string $cloudId the cloud id of the federated contact
+ * @param string $email the email of the federated contact
+ * @param string $name the display name of the federated contact
+ * @param string $userId the uid of the local (inviter) user
+ *
+ * @return array|null the created contact array, or null if a contact with
+ * that cloud id already exists or there is no suitable
+ * writable address book for the inviter
+ */
+ public function createFederatedContact(string $cloudId, string $email, string $name, string $userId): ?array {
+ try {
+ $cm = $this->serverContainer->get(ContactsManager::class);
+ $cm->setupContactsProvider($this->manager, $userId, $this->urlGen);
+
+ $searchResult = $this->manager->search($cloudId, ['CLOUD']);
+ if (count($searchResult) > 0) {
+ $this->logger->info('Contact with cloud id ' . $cloudId . ' already exists.', ['app' => Application::APP_ID]);
+ return null;
+ }
+
+ $addressBook = $this->pickAddressBookForContactCreation($this->manager->getUserAddressBooks());
+ if (!isset($addressBook)) {
+ $this->logger->error('No suitable address book found. Unable to add the new contact on invite accepted.', ['app' => Application::APP_ID]);
+ return null;
+ }
+
+ $newContact = $this->manager->createOrUpdate(
+ [
+ 'FN' => $name,
+ 'EMAIL' => $email,
+ 'CLOUD' => $cloudId,
+ ],
+ $addressBook->getKey()
+ );
+ return $newContact;
+ } catch (Exception $e) {
+ $this->logger->error('An exception occurred creating a federated contact: ' . $e->getTraceAsString(), ['app' => Application::APP_ID]);
+ }
+ return null;
+ }
+
+ /**
+ * Pick a destination book using the same order as ImportController:
+ * personal address book first, then first writable non-shared.
+ */
+ private function pickAddressBookForContactCreation(array $addressBooks): ?IAddressBook {
+ $creatableAddressBooks = array_filter(
+ $addressBooks,
+ static fn (IAddressBook $addressBook): bool => $addressBook instanceof ICreateContactFromString,
+ );
+
+ foreach ($creatableAddressBooks as $addressBook) {
+ if ($addressBook->getUri() === CardDavBackend::PERSONAL_ADDRESSBOOK_URI) {
+ return $addressBook;
+ }
+ }
+
+ foreach ($creatableAddressBooks as $addressBook) {
+ if ($addressBook->isShared()) {
+ continue;
+ }
+ if (($addressBook->getPermissions() & Constants::PERMISSION_CREATE) === 0) {
+ continue;
+ }
+ return $addressBook;
+ }
+
+ return null;
+ }
+
/**
* checks an addressbook is existing
*
diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php
index efc2a15290..046901d8eb 100644
--- a/lib/Settings/AdminSettings.php
+++ b/lib/Settings/AdminSettings.php
@@ -8,6 +8,7 @@
namespace OCA\Contacts\Settings;
use OCA\Contacts\AppInfo\Application;
+use OCA\Contacts\Service\FederatedInvitesService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
@@ -16,28 +17,24 @@
class AdminSettings implements ISettings {
protected $appName;
- /**
- * Admin constructor.
- *
- * @param IConfig $config
- * @param IL10N $l
- */
public function __construct(
private IConfig $config,
private IInitialState $initialState,
+ private FederatedInvitesService $federatedInvitesService,
) {
$this->appName = Application::APP_ID;
}
- /**
- * @return TemplateResponse
- */
#[\Override]
- public function getForm() {
+ public function getForm(): TemplateResponse {
foreach (Application::AVAIL_SETTINGS as $key => $default) {
$data = $this->config->getAppValue($this->appName, $key, $default);
$this->initialState->provideInitialState($key, $data);
}
+ $this->initialState->provideInitialState(
+ 'ocmInvitesConfig',
+ $this->federatedInvitesService->getOcmInvitesConfig(),
+ );
return new TemplateResponse($this->appName, 'settings/admin');
}
diff --git a/lib/WayfProvider.php b/lib/WayfProvider.php
new file mode 100644
index 0000000000..0da1a50c9e
--- /dev/null
+++ b/lib/WayfProvider.php
@@ -0,0 +1,148 @@
+appConfig->getValueString(Application::APP_ID, ConfigLexicon::MESH_PROVIDERS_SERVICE)));
+ $federations = [];
+ $ourServerUrlParts = parse_url($this->urlGenerator->getAbsoluteUrl('/'));
+ $ourFqdn = is_array($ourServerUrlParts) && isset($ourServerUrlParts['host']) ? (string)$ourServerUrlParts['host'] : '';
+
+ $found = [];
+ foreach ($urls as $url) {
+ if ($url === '') {
+ continue;
+ }
+ try {
+ $res = $this->httpClient->newClient()->get($url);
+ $code = $res->getStatusCode();
+ if (!($code >= 200 && $code < 400)) {
+ continue;
+ }
+ $data = json_decode($res->getBody(), true);
+ $fed = $data['federation'] ?? 'Unknown';
+ $federations[$fed] = $federations[$fed] ?? [];
+
+ $servers = is_array($data['servers'] ?? null) ? $data['servers'] : [];
+ foreach ($servers as $prov) {
+ $providerUrl = is_array($prov) && isset($prov['url']) ? (string)$prov['url'] : '';
+ if ($providerUrl === '') {
+ continue;
+ }
+ $fqdn = parse_url($providerUrl, PHP_URL_HOST);
+ if (!is_string($fqdn) || $fqdn === '') {
+ continue;
+ }
+ if (($ourFqdn !== '' && $ourFqdn === $fqdn) || in_array($fqdn, $found, true)) {
+ continue;
+ }
+ try {
+ $disc = $this->discovery->discover($providerUrl, true);
+ $inviteAcceptDialog = $disc->getInviteAcceptDialog();
+ } catch (Exception $e) {
+ $this->logger->error('Discovery failed for ' . $providerUrl . ': ' . $e->getMessage(), ['app' => Application::APP_ID]);
+ continue;
+ }
+ if ($inviteAcceptDialog === '') {
+ // We fall back on Nextcloud default path
+ $inviteAcceptDialogPath = self::getInviteAcceptDialogPath();
+ $inviteAcceptDialog = rtrim($providerUrl, '/') . $inviteAcceptDialogPath;
+ }
+ $federations[$fed][] = [
+ 'provider' => $disc->getProvider(),
+ 'name' => (string)($prov['displayName'] ?? $fqdn),
+ 'fqdn' => $fqdn,
+ 'inviteAcceptDialog' => $inviteAcceptDialog,
+ ];
+ array_push($found, $fqdn);
+ }
+ usort($federations[$fed], fn ($a, $b) => strcmp($a['name'], $b['name']));
+ } catch (Exception $e) {
+ $this->logger->error('Fetch failed for ' . $url . ': ' . $e->getMessage(), ['app' => Application::APP_ID]);
+ }
+ }
+ return $federations;
+ }
+
+ /**
+ * Returns all mesh providers from cache if possible.
+ *
+ * @return array an array containing all mesh providers
+ */
+ public function getMeshProvidersFromCache(): array {
+ $data = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::FEDERATIONS_CACHE, [], true);
+ $expires = is_array($data) && array_key_exists('expires', $data) ? (int)$data['expires'] : 0;
+ if (is_array($data) && $expires > time()) {
+ $this->logger->debug('Cache hit, expires at: ' . $expires, ['app' => Application::APP_ID]);
+ unset($data['expires']);
+ return $data;
+ }
+
+ $this->logger->debug('Cache miss or expired: cron job should update providers.', ['app' => Application::APP_ID]);
+ return $this->getMeshProviders();
+ }
+
+ /**
+ * Returns the WAYF (Where Are You From) login page endpoint to be used in the invitation link.
+ * Can be read from the app config key in ConfigLexicon::WAYF_ENDPOINT.
+ * If not set the endpoint the WAYF page implementation of this app is returned.
+ * Note that the invitation link still needs the token and provider parameters, eg. "https://?token=$token&provider=$provider"
+ *
+ * Security: the value of ConfigLexicon::WAYF_ENDPOINT is used as the base of every
+ * outgoing invitation URL. It is administrator-only configuration and
+ * must point to a trusted WAYF page that the recipient can safely visit.
+ * Setting it to an attacker-controlled origin would let invite links
+ * leak the token and provider query parameters to a third party.
+ *
+ * @return string|null the WAYF login page endpoint or null if it could not be created
+ */
+ public function getWayfEndpoint(): ?string {
+ // default wayf endpoint
+ $defaultWayfEndpoint = $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.federatedinvites.wayf');
+ $configuredEndpoint = trim($this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::WAYF_ENDPOINT));
+ return $configuredEndpoint === '' ? $defaultWayfEndpoint : $configuredEndpoint;
+ }
+
+ /**
+ * Returns the path of the invite accept dialog route.
+ *
+ * @return string
+ */
+ public function getInviteAcceptDialogPath(): string {
+ return $this->urlGenerator->linkToRoute(Application::APP_ID . '.federatedinvites.inviteacceptdialog');
+ }
+}
diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue
index 0d79be5c7f..ad6b88c377 100644
--- a/src/components/AdminSettings.vue
+++ b/src/components/AdminSettings.vue
@@ -12,28 +12,76 @@
v-model="allowSocialSync"
type="checkbox"
class="checkbox"
- @change="updateSetting('allowSocialSync')">
+ @change="updateSocialSetting('allowSocialSync')">
+ {{ t('contacts', 'Accepting will add the inviter to your contacts list and in return, your contact info will be sent to the inviter. From there on you can start sharing data with each other.') }}
+
{{ t('contacts', 'The recipient will receive an email with the invite link. Their email address will be saved on the invite so you can resend later.') }}
{{ t('contacts', 'Accepting will add the inviter to your contacts list and in return, your contact info will be sent to the inviter. From there on you can start sharing data with each other.') }}
{{ t('contacts', 'Invite someone outside your organization to collaborate.') }}
+
{{ t('contacts', 'After the invitee accepts the invite, both of you will appear in each other\'s contacts list and you can start sharing data with each other.') }}
+
+
+
+
+ {{ t('contacts', 'A name or note to help you identify this invite') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('contacts', 'If you do not send an email, you will need to share the invite link yourself.') }}
+