Skip to content

Commit 97bbf10

Browse files
committed
feat: Create invitation links
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
1 parent d4ca7e0 commit 97bbf10

File tree

16 files changed

+729
-117
lines changed

16 files changed

+729
-117
lines changed

appinfo/routes.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
['name' => 'contacts#direct', 'url' => '/direct/contact/{contact}', 'verb' => 'GET'],
1111
['name' => 'contacts#directcircle', 'url' => '/direct/circle/{singleId}', 'verb' => 'GET'],
1212
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
13+
14+
['name' => 'invitation#invite', 'url' => '/api/v1/circles/{singleId}/invitation', 'verb' => 'POST'],
15+
['name' => 'invitation#revokeInvitation', 'url' => '/api/v1/circles/{singleId}/invitation', 'verb' => 'DELETE'],
16+
['name' => 'invitation#viewInvitation', 'url' => '/join/{invitationCode}', 'verb' => 'GET'],
17+
['name' => 'invitation#acceptInvitation', 'url' => '/api/v1/join/{invitationCode}', 'verb' => 'POST'],
18+
1319
['name' => 'page#index', 'url' => '/{group}', 'verb' => 'GET', 'postfix' => 'group'],
1420
['name' => 'page#index', 'url' => '/{group}/{contact}', 'verb' => 'GET', 'postfix' => 'group.contact'],
1521
['name' => 'social_api#update_contact', 'url' => '/api/v1/social/avatar/{network}/{addressbookId}/{contactId}', 'verb' => 'PUT'],
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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 OC\DB\Connection;
11+
use OCA\Circles\Db\CircleRequest;
12+
use OCA\Circles\Db\MembershipRequest;
13+
use OCA\Circles\Exceptions\FrontendException;
14+
use OCA\Circles\Exceptions\MembershipNotFoundException;
15+
use OCA\Circles\FederatedItems\CircleJoin;
16+
use OCA\Circles\Model\Federated\FederatedEvent;
17+
use OCA\Circles\Service\CircleService;
18+
use OCA\Circles\Service\ConfigService;
19+
use OCA\Circles\Service\FederatedUserService;
20+
use OCA\Circles\Service\TimezoneService;
21+
use OCA\Circles\Tools\Traits\TDeserialize;
22+
use OCA\Circles\Tools\Traits\TNCLogger;
23+
use OCA\Contacts\AppInfo\Application;
24+
use OCP\AppFramework\ApiController;
25+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
26+
use OCP\AppFramework\Http\DataResponse;
27+
use OCP\AppFramework\Http\TemplateResponse;
28+
use OCP\AppFramework\OCS\OCSException;
29+
use OCP\AppFramework\Services\IInitialState;
30+
use OCP\IL10N;
31+
use OCP\IRequest;
32+
use OCP\IUserSession;
33+
use OCP\Security\ISecureRandom;
34+
35+
class InvitationController extends ApiController {
36+
private const SETTING_KEY_INVITATION_CODE = 'invitationCode';
37+
38+
use TDeserialize;
39+
use TNCLogger;
40+
41+
public function __construct(
42+
IRequest $request,
43+
private IUserSession $userSession,
44+
private ISecureRandom $random,
45+
private CircleService $circleService,
46+
private ConfigService $configService,
47+
private FederatedUserService $federatedUserService,
48+
private Connection $connection,
49+
private TimezoneService $timezoneService,
50+
private IInitialState $initialState,
51+
private CircleRequest $circleRequest,
52+
private MembershipRequest $membershipRequest,
53+
private CircleJoin $circleJoin,
54+
private IL10N $l10n,
55+
) {
56+
parent::__construct(Application::APP_ID, $request);
57+
}
58+
59+
/**
60+
* @NoAdminRequired
61+
*/
62+
public function invite(string $singleId): DataResponse {
63+
$invitationCode = $this->generateCode();
64+
65+
try {
66+
$this->setCurrentFederatedUser();
67+
68+
$outcome = $this->circleService->updateSetting($singleId, self::SETTING_KEY_INVITATION_CODE, $invitationCode);
69+
70+
$this->deleteInvitationCode($singleId);
71+
$this->insertInvitationCode($singleId, $invitationCode);
72+
73+
return new DataResponse($this->serializeArray($outcome));
74+
} catch (\Exception $e) {
75+
$this->e($e, ['circleId' => $singleId]);
76+
throw new OCSException($e->getMessage(), (int)$e->getCode(), $e);
77+
}
78+
}
79+
80+
/**
81+
* @NoAdminRequired
82+
*/
83+
public function revokeInvitation(string $singleId): DataResponse {
84+
try {
85+
$this->setCurrentFederatedUser();
86+
87+
$outcome = $this->circleService->updateSetting($singleId, self::SETTING_KEY_INVITATION_CODE, $invitationCode);
88+
89+
$this->deleteInvitationCode($singleId);
90+
91+
return new DataResponse($this->serializeArray($outcome));
92+
} catch (\Exception $e) {
93+
$this->e($e, ['circleId' => $singleId]);
94+
throw new OCSException($e->getMessage(), (int)$e->getCode(), $e);
95+
}
96+
}
97+
98+
/**
99+
* @NoAdminRequired
100+
* @NoCSRFRequired
101+
*
102+
* @UserRateThrottle(limit=10, period=3600)
103+
*/
104+
#[UserRateLimit(limit: 10, period: 3600)]
105+
public function viewInvitation(string $invitationCode): TemplateResponse {
106+
$this->setCurrentFederatedUser();
107+
108+
try {
109+
$invitation = $this->getInvitation($invitationCode);
110+
} catch (\OutOfBoundsException $e) {
111+
return new TemplateResponse(Application::APP_ID,
112+
'message',
113+
['message' => $this->l10n->t('Link or team does not exist anymore')],
114+
TemplateResponse::RENDER_AS_USER,
115+
404,
116+
);
117+
}
118+
119+
$circle = $this->circleRequest->getCircle($invitation['circle_id']);
120+
$federatedUser = $this->federatedUserService->getLocalFederatedUser($this->userSession->getUser()->getUID());
121+
122+
try {
123+
$this->membershipRequest->getMembership($invitation['circle_id'], $federatedUser->getSingleId());
124+
$isAlreadyMemberOfCircle = true;
125+
} catch (MembershipNotFoundException) {
126+
$isAlreadyMemberOfCircle = false;
127+
}
128+
129+
$this->initialState->provideInitialState('circleIdToJoin', $invitation['circle_id']);
130+
$this->initialState->provideInitialState('circleNameToJoin', $circle->getName());
131+
$this->initialState->provideInitialState('invitationCode', $invitationCode);
132+
$this->initialState->provideInitialState('isAlreadyMemberOfCircle', $isAlreadyMemberOfCircle);
133+
134+
return new TemplateResponse(Application::APP_ID, 'join');
135+
}
136+
137+
/**
138+
* @NoAdminRequired
139+
* @NoCSRFRequired
140+
*
141+
* @UserRateThrottle(limit=10, period=3600)
142+
*/
143+
#[UserRateLimit(limit: 10, period: 3600)]
144+
public function acceptInvitation(string $invitationCode): DataResponse {
145+
$joined = false;
146+
try {
147+
$invitation = $this->getInvitation($invitationCode);
148+
149+
$circle = $this->circleRequest->getCircle($invitation['circle_id']);
150+
$userId = $this->userSession->getUser()->getUID();
151+
$federatedInvitedUser = $this->federatedUserService->getLocalFederatedUser($userId);
152+
$federatedInvitedMember = $this->federatedUserService->getFederatedMember($userId);
153+
154+
try {
155+
$this->membershipRequest->getMembership($invitation['circle_id'], $federatedInvitedUser->getSingleId());
156+
} catch (MembershipNotFoundException) {
157+
$event = new FederatedEvent(CircleJoin::class);
158+
$event->setCircle($circle);
159+
$federatedInvitedMember->setInvitedBy($federatedInvitedUser);
160+
$circle->setInitiator($federatedInvitedMember);
161+
162+
// fixme: implement
163+
$this->circleJoin->lightJoin($event);
164+
165+
$joined = true;
166+
}
167+
168+
return new DataResponse(['joined' => $joined]);
169+
} catch (Exception $e) {
170+
$this->e($e, ['circleId' => $invitation]);
171+
throw new OCSException($e->getMessage(), (int)$e->getCode());
172+
}
173+
}
174+
175+
private function setCurrentFederatedUser(): void {
176+
if (!$this->configService->getAppValueBool(ConfigService::FRONTEND_ENABLED)) {
177+
throw new FrontendException('frontend disabled');
178+
}
179+
180+
$user = $this->userSession->getUser();
181+
$this->federatedUserService->setLocalCurrentUser($user);
182+
}
183+
184+
private function generateCode($length = 16) {
185+
return $this->random->generate(
186+
$length,
187+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
188+
);
189+
}
190+
191+
private function insertInvitationCode(string $circleId, string $invitationCode): void {
192+
$user = $this->userSession->getUser();
193+
194+
$qb = $this->connection->getQueryBuilder();
195+
$qb->insert('contacts_circle_invitations');
196+
197+
$qb->setValue('circle_id', $qb->createNamedParameter($circleId))
198+
->setValue('invitation_code', $qb->createNamedParameter($invitationCode))
199+
->setValue('created_by', $qb->createNamedParameter($user->getUID()))
200+
->setValue('created', $qb->createNamedParameter($this->timezoneService->getUTCDate()));
201+
202+
$qb->execute();
203+
}
204+
205+
private function deleteInvitationCode(string $circleId): void {
206+
$qb = $this->connection->getQueryBuilder();
207+
$qb->delete('contacts_circle_invitations');
208+
$qb->where($qb->expr()->eq('circle_id', $qb->createNamedParameter($circleId)));
209+
210+
$qb->execute();
211+
}
212+
213+
/**
214+
* @param string $invitationCode
215+
* @return array{circle_id: string, created_by: string}
216+
*/
217+
private function getInvitation(string $invitationCode): array {
218+
$invitationCode = str_replace('-', '', $invitationCode);
219+
220+
$qb = $this->connection->getQueryBuilder();
221+
$row = $qb->select('circle_id', 'created_by')
222+
->from('contacts_circle_invitations')
223+
->where($qb->expr()->eq('invitation_code', $qb->createNamedParameter($invitationCode)))
224+
->executeQuery()
225+
->fetch();
226+
227+
if (empty($row)) {
228+
throw new \OutOfBoundsException('Invitation code not found');
229+
}
230+
231+
return $row;
232+
}
233+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
6+
/**
7+
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
8+
* SPDX-License-Identifier: AGPL-3.0-or-later
9+
*/
10+
11+
namespace OCA\Contacts\Migration;
12+
13+
use Closure;
14+
use Doctrine\DBAL\Schema\SchemaException;
15+
use OCP\DB\ISchemaWrapper;
16+
use OCP\Migration\IOutput;
17+
use OCP\Migration\SimpleMigrationStep;
18+
19+
class Version8100Date20261129153333 extends SimpleMigrationStep {
20+
/**
21+
* @param IOutput $output
22+
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
23+
* @param array $options
24+
*
25+
* @return null|ISchemaWrapper
26+
* @throws SchemaException
27+
*/
28+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
29+
/** @var ISchemaWrapper $schema */
30+
$schema = $schemaClosure();
31+
32+
if (!$schema->hasTable('contacts_circle_invitations')) {
33+
$table = $schema->createTable('contacts_circle_invitations');
34+
35+
$table->addColumn(
36+
'id', 'integer', [
37+
'autoincrement' => true,
38+
'notnull' => true,
39+
'length' => 8,
40+
'unsigned' => true,
41+
]
42+
);
43+
$table->addColumn(
44+
'circle_id', 'string', [
45+
'length' => 32,
46+
'notnull' => true,
47+
]
48+
);
49+
$table->addColumn(
50+
'invitation_code', 'string', [
51+
'length' => 16,
52+
'notnull' => true,
53+
]
54+
);
55+
$table->addColumn(
56+
'created_by', 'string', [
57+
'length' => 255,
58+
'notnull' => true,
59+
]
60+
);
61+
$table->addColumn(
62+
'created', 'datetime', [
63+
'notnull' => true,
64+
]
65+
);
66+
67+
$table->setPrimaryKey(['id']);
68+
$table->addUniqueIndex(['circle_id']);
69+
$table->addUniqueIndex(['invitation_code']);
70+
}
71+
72+
return $schema;
73+
}
74+
}

0 commit comments

Comments
 (0)