Skip to content

Commit ca42433

Browse files
committed
Implement invite accept dialog route.
1 parent c7c67da commit ca42433

File tree

12 files changed

+978
-5
lines changed

12 files changed

+978
-5
lines changed

appinfo/routes.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,19 @@
44
* SPDX-License-Identifier: AGPL-3.0-or-later
55
*/
66

7+
// OCM: the path to the invite accept dialog
8+
// TODO read from config
9+
$inviteAcceptDialogPath = '/ocm/invite-accept-dialog';
10+
711
return [
812
'routes' => [
913
['name' => 'contacts#direct', 'url' => '/direct/contact/{contact}', 'verb' => 'GET'],
1014
['name' => 'contacts#directcircle', 'url' => '/direct/circle/{singleId}', 'verb' => 'GET'],
15+
16+
['name' => 'federated_invites#create_invite', 'url' => '/ocm/invitations', 'verb' => 'POST'],
17+
['name' => 'federated_invites#invite_accepted', 'url' => '/ocm/invitations/accept', 'verb' => 'PATCH'],
18+
['name' => 'federated_invites#invite_accept_dialog', 'url' => $inviteAcceptDialogPath, 'verb' => 'GET'],
19+
1120
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
1221
['name' => 'page#index', 'url' => '/{group}', 'verb' => 'GET', 'postfix' => 'group'],
1322
['name' => 'page#index', 'url' => '/{group}/{contact}', 'verb' => 'GET', 'postfix' => 'group.contact'],
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\Contacts\Controller;
9+
10+
use Exception;
11+
use OC\App\CompareVersion;
12+
use OCA\DAV\CardDAV\CardDavBackend;
13+
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
14+
use OCA\Contacts\AppInfo\Application;
15+
use OCA\Contacts\Service\GroupSharingService;
16+
use OCA\Contacts\Service\SocialApiService;
17+
use OCA\FederatedFileSharing\AddressHandler;
18+
use OCP\App\IAppManager;
19+
use OCP\AppFramework\Http;
20+
use OCP\AppFramework\Http\DataResponse;
21+
use OCP\AppFramework\Http\TemplateResponse;
22+
use OCP\Contacts\IManager;
23+
use OCP\Http\Client\IClientService;
24+
use OCP\IConfig;
25+
use OCP\IInitialStateService;
26+
use OCP\IL10N;
27+
use OCP\IRequest;
28+
use OCP\IURLGenerator;
29+
use OCP\IUserManager;
30+
use OCP\IUserSession;
31+
use OCP\L10N\IFactory;
32+
use Psr\Log\LoggerInterface;
33+
34+
/**
35+
* Controller for federated invites related routes.
36+
*
37+
*/
38+
39+
class FederatedInvitesController extends PageController
40+
{
41+
42+
public function __construct(
43+
IRequest $request,
44+
private AddressHandler $addressHandler,
45+
private CardDavBackend $cardDavBackend,
46+
private IClientService $httpClient,
47+
private IConfig $config,
48+
private IInitialStateService $initialStateService,
49+
private IFactory $languageFactory,
50+
private IManager $contactsManager,
51+
private IUserSession $userSession,
52+
private SocialApiService $socialApiService,
53+
private IAppManager $appManager,
54+
private CompareVersion $compareVersion,
55+
private GroupSharingService $groupSharingService,
56+
private IL10N $il10,
57+
private IURLGenerator $urlGenerator,
58+
private IUserManager $userManager,
59+
private FederatedInviteMapper $federatedInviteMapper,
60+
private LoggerInterface $logger,
61+
) {
62+
parent::__construct(
63+
$request,
64+
$config,
65+
$initialStateService,
66+
$languageFactory,
67+
$userSession,
68+
$socialApiService,
69+
$appManager,
70+
$compareVersion,
71+
$groupSharingService,
72+
$logger,
73+
);
74+
}
75+
76+
/**
77+
* @NoAdminRequired
78+
* @NoCSRFRequired
79+
*
80+
* Sets the token and provider states which triggers display of the invite accept dialog.
81+
*
82+
* @param string $token
83+
* @param string $provider
84+
*/
85+
public function inviteAcceptDialog(string $token = "", string $provider = ""): TemplateResponse
86+
{
87+
$this->logger->debug(" - FederatedInvitesController inviteAcceptDialog method: setting initial state ($token, $provider) and returning PageController index ", ['app' => Application::APP_ID]);
88+
$this->initialStateService->provideInitialState(Application::APP_ID, 'inviteToken', $token);
89+
$this->initialStateService->provideInitialState(Application::APP_ID, 'inviteProvider', $provider);
90+
// TODO read from config
91+
$this->initialStateService->provideInitialState(Application::APP_ID, 'acceptInviteDialogUrl', '/ocm/invite-accept-dialog');
92+
93+
return $this->index();
94+
}
95+
96+
/**
97+
* @NoAdminRequired
98+
* @NoCSRFRequired
99+
*
100+
* Creates an invitation to exchange contact info for the user with the specified uid.
101+
*
102+
* @return DataResponse
103+
*/
104+
public function createInvite(string $uid = ''): DataResponse
105+
{
106+
return new DataResponse(['message' => "Route not implemented"], Http::STATUS_NOT_IMPLEMENTED);
107+
}
108+
109+
/**
110+
* @NoAdminRequired
111+
* @NoCSRFRequired
112+
*
113+
* Accepts the invite and creates a new contact for it.
114+
* On success the user will be redirected to the contacts list with the newly created contact in focus.
115+
*
116+
* @param string $token the token of the invite
117+
* @param string $provider the provider of the sender of the invite
118+
* @return DataResponse with data signature ['contact' | 'message'] - the new contact url or a message in case of error
119+
*/
120+
public function inviteAccepted(string $token = "", string $provider = ""): DataResponse {
121+
if ($token === "" || $provider === "") {
122+
$this->logger->error("Both token and provider must be specified. Received: token=$token, provider=$provider", ['app' => Application::APP_ID]);
123+
return new DataResponse(['message' => 'Both token and provider must be specified.'], Http::STATUS_NOT_FOUND);
124+
}
125+
try {
126+
// delegate further to OCM /invite-accepted
127+
// this returns a response with the following data signature: ['userID', 'email', 'name']
128+
// @link https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post
129+
$localUser = $this->userSession->getUser();
130+
$client = $this->httpClient->newClient();
131+
$responseData = null;
132+
$response = $client->post(
133+
// TODO get the correct url in an appropriate manner
134+
"http://nc-ocm.nl/ocm/invite-accepted",
135+
[
136+
'body' =>
137+
[
138+
'recipientProvider' => $provider,
139+
'token' => $token,
140+
'userId' => $localUser->getUID(),
141+
'email' => $localUser->getEMailAddress(),
142+
'name' => $localUser->getDisplayName(),
143+
],
144+
'connect_timeout' => 10,
145+
]
146+
);
147+
$responseData = $response->getBody();
148+
$data = json_decode($responseData, true);
149+
$newContact = $this->socialApiService->createFederatedContact(
150+
// nextcloud cloud id format, ie. the ocm address
151+
$data['userID'] . "@" . $this->addressHandler->removeProtocolFromUrl($provider),
152+
$data['email'],
153+
$data['name'],
154+
$localUser->getUID(),
155+
);
156+
if (!isset($newContact)) {
157+
return new DataResponse(['message' => 'An unexpected error occurred trying to accept invite: could not create new contact'], Http::STATUS_NOT_FOUND);
158+
}
159+
$this->logger->info("Created new contact with UID: " . $newContact['UID'] . " for user with UID: " . $localUser->getUID(), ['app' => Application::APP_ID]);
160+
161+
$contact = $newContact['UID'] . "~" . CardDavBackend::PERSONAL_ADDRESSBOOK_URI;
162+
$url = $this->urlGenerator->getAbsoluteURL(
163+
$this->urlGenerator->linkToRoute('contacts.page.index') . $this->il10->t('All contacts') . '/' . $contact
164+
);
165+
return new DataResponse(['contact' => $url], Http::STATUS_OK);
166+
} catch (\GuzzleHttp\Exception\RequestException $e) {
167+
$this->logger->error("/invite-accepted returned an error: " . print_r($responseData, true), ['app' => Application::APP_ID]);
168+
/**
169+
* 400: Invalid or non existing token
170+
* 409: Invite already accepted
171+
*/
172+
$statusCode = $e->getCode();
173+
switch ($statusCode) {
174+
case Http::STATUS_BAD_REQUEST:
175+
return new DataResponse(['message' => 'Invalid, non existing or expired token'], $e->getCode());
176+
case Http::STATUS_CONFLICT:
177+
return new DataResponse(['message' => 'Invite already accepted'], $e->getCode());
178+
}
179+
$this->logger->error("An unexpected error occurred accepting invite with token=$token and provider=$provider. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
180+
return new DataResponse(['message' => 'An unexpected error occurred trying to accept invite.'], Http::STATUS_NOT_FOUND);
181+
} catch (Exception $e) {
182+
$this->logger->error("An unexpected error occurred accepting invite with token=$token and provider=$provider. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
183+
return new DataResponse(['message' => 'An unexpected error occurred trying to accept invite'], Http::STATUS_NOT_FOUND);
184+
}
185+
}
186+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Contacts\Listener;
11+
12+
use Exception;
13+
use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent;
14+
use OCA\Contacts\AppInfo\Application;
15+
use OCA\Contacts\Service\SocialApiService;
16+
use OCA\FederatedFileSharing\AddressHandler;
17+
use OCP\EventDispatcher\Event;
18+
use OCP\EventDispatcher\IEventListener;
19+
use Psr\Log\LoggerInterface;
20+
21+
/**
22+
* Listens to the federated invite accepted event.
23+
* Catching the event should lead to the creation of the new remote contact from the invite.
24+
*/
25+
class FederatedInviteAcceptedListener implements IEventListener
26+
{
27+
28+
public function __construct(
29+
private AddressHandler $addressHandler,
30+
private SocialApiService $socialApiService,
31+
private LoggerInterface $logger
32+
) {}
33+
34+
/**
35+
* Handles the FederatedInviteAcceptedEvent that is dispatched by the server when an invite has been accepted.
36+
* The accepted invitation is enclosed in the event.
37+
* Creates and saves a new contact in the address book of the sender of the invitation.
38+
*
39+
* @param Event $event an event of type FederatedInviteAcceptedEvent
40+
* @return void
41+
*/
42+
public function handle(Event $event): void {
43+
// Note that there is no user session and we create a contact for the sender of the invite.
44+
if ($event instanceof FederatedInviteAcceptedEvent) {
45+
// the event holds the invitation
46+
$invitation = $event->getInvitation();
47+
// the sender uid
48+
$userId = $invitation->getUserId();
49+
try {
50+
$newContact = $this->socialApiService->createFederatedContact(
51+
$invitation->getRecipientUserId() . "@" . $this->addressHandler->removeProtocolFromUrl($invitation->getRecipientProvider()),
52+
$invitation->getRecipientEmail(),
53+
$invitation->getRecipientName(),
54+
$userId,
55+
);
56+
if (isset($newContact)) {
57+
$this->logger->info("Created new contact with UID: " . $newContact['UID'] . " for user with UID: $userId", ['app' => Application::APP_ID]);
58+
}
59+
} catch (Exception $e) {
60+
$this->logger->error("An unexpected error occurred creating a new contact. Stacktrace: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
61+
}
62+
} else {
63+
$this->logger->error("Expected an event of type FederatedInviteAcceptedEvent, but got " . get_class($event) . " instead.", ['app' => Application::APP_ID]);
64+
}
65+
}
66+
}

lib/Service/SocialApiService.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace OCA\Contacts\Service;
1111

12+
use Exception;
1213
use OCA\Contacts\AppInfo\Application;
1314
use OCA\Contacts\Service\Social\CompositeSocialProvider;
1415
use OCA\DAV\CardDAV\ContactsManager;
@@ -22,6 +23,7 @@
2223
use OCP\IL10N;
2324
use OCP\IURLGenerator;
2425
use Psr\Container\ContainerInterface;
26+
use Psr\Log\LoggerInterface;
2527

2628
class SocialApiService {
2729
private $appName;
@@ -36,6 +38,7 @@ public function __construct(
3638
private IURLGenerator $urlGen,
3739
private ITimeFactory $timeFactory,
3840
private ImageResizer $imageResizer,
41+
private LoggerInterface $logger
3942
) {
4043
$this->appName = Application::APP_ID;
4144
}
@@ -215,6 +218,61 @@ public function updateContact(string $addressbookId, string $contactId, ?string
215218
return new JSONResponse([], Http::STATUS_OK);
216219
}
217220

221+
/**
222+
* Creates a federated contact and adds it to the address book of the local user with the specified userId,
223+
* unless a contact with the specified cloudId already exists for that local user.
224+
*
225+
* @param {string} cloudId the cloud id of the federated contact
226+
* @param {string} email the email of the federated contact
227+
* @param {string} name the name of the federated contact
228+
* @param {string} userId the uid of the local user
229+
*/
230+
public function createFederatedContact(string $cloudId, string $email, string $name, string $userId): array|null {
231+
try {
232+
// Set up the contacts provider for the user with the specified uid
233+
$cm = $this->serverContainer->get(ContactsManager::class);
234+
$cm->setupContactsProvider($this->manager, $userId, $this->urlGen);
235+
236+
// if contact already exists we simply return
237+
$searchResult = $this->manager->search($cloudId, ['CLOUD']);
238+
if (count($searchResult) > 0) {
239+
$this->logger->info("Contact with cloud id " . $cloudId . " already exists.", ['app' => Application::APP_ID]);
240+
return null;
241+
}
242+
243+
/** @var \OCP\IAddressBook */
244+
$addressBook = null;
245+
$addressBooks = $this->manager->getUserAddressBooks();
246+
foreach ($addressBooks as $_addressBook) {
247+
// TODO properly resolve the correct addressbook to add the contact to
248+
// Resolve by uri seems a bit risky ... can we be sure the uri equals 'contacts' ?
249+
// Perhaps add to the first 'non system' addressbook we find ?
250+
// (although we still would like to add to the 'Contacts' addressbook I guess)
251+
if ($_addressBook->getUri() === 'contacts') {
252+
$addressBook = $_addressBook;
253+
break;
254+
}
255+
}
256+
if (!isset($addressBook)) {
257+
$this->logger->error("Contacts address book not found. Unable to add the new contact on invite accepted.", ['app' => Application::APP_ID]);
258+
return null;
259+
}
260+
261+
$newContact = $this->manager->createOrUpdate(
262+
[
263+
'FN' => $name,
264+
'EMAIL' => $email,
265+
'CLOUD' => $cloudId,
266+
],
267+
$addressBook->getKey()
268+
);
269+
return $newContact;
270+
} catch (Exception $e) {
271+
$this->logger->error("An exception occurred creating a federated contact: " . $e->getTraceAsString(), ['app' => Application::APP_ID]);
272+
}
273+
return null;
274+
}
275+
218276
/**
219277
* checks an addressbook is existing
220278
*

0 commit comments

Comments
 (0)