From ab2ab916fa8817b6c9207ae40673e1bfc30b1ec2 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sat, 29 Nov 2025 23:38:19 +0100 Subject: [PATCH] feat: Add the possibility to create invitation links to circles Signed-off-by: Kostiantyn Miakshyn --- appinfo/info.xml | 2 +- appinfo/routes.php | 4 + lib/Controller/LocalController.php | 91 +++++++++++++++ lib/Db/CircleInvitationRequest.php | 54 +++++++++ lib/Db/CircleRequest.php | 5 + lib/Db/CircleRequestBuilder.php | 14 ++- lib/Db/CoreQueryBuilder.php | 57 ++++++++- lib/Db/CoreRequestBuilder.php | 7 ++ .../CircleInvitationNotFoundException.php | 14 +++ lib/FederatedItems/CircleCreateInvitation.php | 65 +++++++++++ lib/FederatedItems/CircleJoin.php | 11 +- lib/FederatedItems/CircleRevokeInvitation.php | 52 +++++++++ .../Version8100Date20261129153333.php | 74 ++++++++++++ lib/Model/Circle.php | 28 +++++ lib/Model/CircleInvitation.php | 110 ++++++++++++++++++ lib/Model/ModelManager.php | 15 +++ lib/Model/Probes/CircleProbe.php | 28 +++++ lib/Service/CircleService.php | 67 ++++++++++- 18 files changed, 686 insertions(+), 12 deletions(-) create mode 100644 lib/Db/CircleInvitationRequest.php create mode 100644 lib/Exceptions/CircleInvitationNotFoundException.php create mode 100644 lib/FederatedItems/CircleCreateInvitation.php create mode 100644 lib/FederatedItems/CircleRevokeInvitation.php create mode 100644 lib/Migration/Version8100Date20261129153333.php create mode 100644 lib/Model/CircleInvitation.php diff --git a/appinfo/info.xml b/appinfo/info.xml index ecbdf8608..1aa78a635 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -12,7 +12,7 @@ Those groups of people can then be used by any other app for sharing purpose. ]]> - 35.0.0-dev.0 + 35.0.0-dev.1 agpl Maxence Lange diff --git a/appinfo/routes.php b/appinfo/routes.php index fce3f8d5b..25b0da7f7 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -38,6 +38,10 @@ ['name' => 'Local#editName', 'url' => '/circles/{circleId}/name', 'verb' => 'PUT'], ['name' => 'Local#editDescription', 'url' => '/circles/{circleId}/description', 'verb' => 'PUT'], ['name' => 'Local#editSetting', 'url' => '/circles/{circleId}/setting', 'verb' => 'PUT'], + ['name' => 'Local#createInvitation', 'url' => '/circles/{circleId}/invitation', 'verb' => 'PUT'], + ['name' => 'Local#revokeInvitation', 'url' => '/circles/{circleId}/invitation', 'verb' => 'DELETE'], + ['name' => 'Local#getInvitation', 'url' => '/invitations/{invitationCode}', 'verb' => 'GET'], + ['name' => 'Local#joinInvitation', 'url' => '/invitations/{invitationCode}', 'verb' => 'POST'], ['name' => 'Local#editConfig', 'url' => '/circles/{circleId}/config', 'verb' => 'PUT'], ['name' => 'Local#circleAvatar', 'url' => '/circles/{circleId}/avatar', 'verb' => 'GET'], ['name' => 'Local#uploadAvatar', 'url' => '/circles/{circleId}/avatar', 'verb' => 'POST'], diff --git a/lib/Controller/LocalController.php b/lib/Controller/LocalController.php index 709302c81..06bafac08 100644 --- a/lib/Controller/LocalController.php +++ b/lib/Controller/LocalController.php @@ -593,6 +593,97 @@ public function link(string $circleId, string $singleId): DataResponse { } } + #[NoAdminRequired] + public function createInvitation(string $circleId): DataResponse { + try { + $this->setCurrentFederatedUser(); + + $outcome = $this->circleService->createInvitation($circleId); + + return new DataResponse($this->serializeArray($outcome)); + } catch (\Exception $e) { + $this->e($e, ['circleId' => $circleId]); + throw new OCSException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + #[NoAdminRequired] + public function revokeInvitation(string $circleId): DataResponse { + try { + $this->setCurrentFederatedUser(); + + $outcome = $this->circleService->revokeInvitation($circleId); + + return new DataResponse($this->serializeArray($outcome)); + } catch (\Exception $e) { + $this->e($e, ['circleId' => $circleId]); + throw new OCSException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + #[NoAdminRequired] + #[BruteForceProtection(action: 'getInvitation')] + public function getInvitation(string $invitationCode): DataResponse { + try { + $this->setCurrentFederatedUser(); + + $circleProbe = (new CircleProbe()) + ->includeSystemCircles() + ->includeHiddenCircles() + ->filterByInvitationCode($invitationCode); + + $circles = $this->circleService->getCircles($circleProbe); + if (empty($circles)) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + $circle = reset($circles); + + $membershipStatus = 'NOT_A_MEMBER'; + if ($circle->hasInitiator()) { + if ($circle->getInitiator()->getLevel() > Member::LEVEL_NONE) { + $membershipStatus = 'MEMBER'; + } elseif ($circle->getInitiator()->getStatus() === Member::STATUS_REQUEST) { + $membershipStatus = 'REQUESTED_MEMBERSHIP'; + } + } + + return new DataResponse([ + 'circleId' => $circle->getSingleId(), + 'circleName' => $circle->getName(), + 'membershipStatus' => $membershipStatus, + ]); + } catch (\Exception $e) { + $this->e($e, ['circleId' => $invitationCode]); + throw new OCSException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + #[NoAdminRequired] + #[BruteForceProtection(action: 'joinInvitation')] + public function joinInvitation(string $invitationCode): DataResponse { + try { + $this->setCurrentFederatedUser(); + + $circleProbe = (new CircleProbe()) + ->includeSystemCircles() + ->includeHiddenCircles() + ->filterByInvitationCode($invitationCode); + + $circles = $this->circleService->getCircles($circleProbe); + if (empty($circles)) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + $circle = reset($circles); + + $result = $this->circleService->circleJoin($circle->getSingleId(), $invitationCode); + + return new DataResponse($this->serializeArray($result)); + } catch (\Exception $e) { + $this->e($e, ['circleId' => $invitationCode]); + throw new OCSException($e->getMessage(), (int)$e->getCode(), $e); + } + } + /** * @throws FederatedUserException * @throws FederatedUserNotFoundException diff --git a/lib/Db/CircleInvitationRequest.php b/lib/Db/CircleInvitationRequest.php new file mode 100644 index 000000000..492214e67 --- /dev/null +++ b/lib/Db/CircleInvitationRequest.php @@ -0,0 +1,54 @@ +confirmValidId($circleInvitation->getCircleId()); + + $qb = $this->getQueryBuilder(); + $qb->insert(self::TABLE_INVITATIONS) + ->setValue('created', $qb->createNamedParameter($this->timezoneService->getUTCDate())); + $qb->setValue('circle_id', $qb->createNamedParameter($circleInvitation->getCircleId())) + ->setValue('invitation_code', $qb->createNamedParameter($circleInvitation->getInvitationCode())) + ->setValue('created_by', $qb->createNamedParameter($circleInvitation->getCreatedBy())); + $qb->executeStatement(); + } + + /** + * @throws InvalidIdException + */ + public function replace(CircleInvitation $circleInvitation): void { + $qb = $this->getQueryBuilder(); + $qb->delete(self::TABLE_INVITATIONS); + $qb->limitToCircleId($circleInvitation->getCircleId()); + $qb->executeStatement(); + + $this->save($circleInvitation); + } + + public function delete(string $circleId): void { + $qb = $this->getQueryBuilder(); + $qb->delete(self::TABLE_INVITATIONS); + $qb->limitToCircleId($circleId); + $qb->executeStatement(); + } +} diff --git a/lib/Db/CircleRequest.php b/lib/Db/CircleRequest.php index 0388c6b4c..f26be7f7c 100644 --- a/lib/Db/CircleRequest.php +++ b/lib/Db/CircleRequest.php @@ -167,6 +167,7 @@ public function getCircles(?IFederatedUser $initiator, CircleProbe $probe): arra $qb->limitToInitiator(CoreQueryBuilder::CIRCLE, $initiator); $qb->orderBy($qb->generateAlias(CoreQueryBuilder::CIRCLE, CoreQueryBuilder::INITIATOR) . '.level', 'desc'); $qb->addOrderBy(CoreQueryBuilder::CIRCLE . '.display_name', 'asc'); + $qb->leftJoinCircleInvitation(CoreQueryBuilder::CIRCLE); } if ($probe->hasFilterMember()) { $qb->limitToDirectMembership(CoreQueryBuilder::CIRCLE, $probe->getFilterMember()); @@ -174,6 +175,9 @@ public function getCircles(?IFederatedUser $initiator, CircleProbe $probe): arra if ($probe->hasFilterCircle()) { $qb->filterCircleDetails($probe->getFilterCircle()); } + if ($probe->hasInvitationCode()) { + $qb->filterInvitationCode(CoreQueryBuilder::CIRCLE, $probe->getInvitationCode()); + } if ($probe->hasFilterRemoteInstance()) { $qb->limitToRemoteInstance(CoreQueryBuilder::CIRCLE, $probe->getFilterRemoteInstance(), false); } @@ -375,6 +379,7 @@ public function getCircle( $qb->limitToUniqueId($id); $qb->filterCircles(CoreQueryBuilder::CIRCLE, $probe); $qb->leftJoinOwner(CoreQueryBuilder::CIRCLE); + $qb->leftJoinCircleInvitation(CoreQueryBuilder::CIRCLE); // $qb->setOptions( // [CoreRequestBuilder::CIRCLE, CoreRequestBuilder::INITIATOR], [ // 'mustBeMember' => false, diff --git a/lib/Db/CircleRequestBuilder.php b/lib/Db/CircleRequestBuilder.php index 1f5a2bad6..62ac44db0 100644 --- a/lib/Db/CircleRequestBuilder.php +++ b/lib/Db/CircleRequestBuilder.php @@ -33,7 +33,6 @@ protected function getCircleInsertSql(): CoreQueryBuilder { return $qb; } - /** * @return CoreQueryBuilder&IQueryBuilder */ @@ -44,7 +43,6 @@ protected function getCircleUpdateSql(): CoreQueryBuilder { return $qb; } - /** * @param string $alias * @param bool $single @@ -65,7 +63,6 @@ protected function getCircleSelectSql( return $qb; } - /** * Base of the Sql Delete request * @@ -78,6 +75,17 @@ protected function getCircleDeleteSql(): CoreQueryBuilder { return $qb; } + /** + * Base of the Sql Delete request + * + * @return CoreQueryBuilder&IQueryBuilder + */ + protected function getCircleInvitationDeleteSql(): CoreQueryBuilder { + $qb = $this->getQueryBuilder(); + $qb->delete(self::TABLE_INVITATIONS); + + return $qb; + } /** * @param CoreQueryBuilder&IQueryBuilder $qb diff --git a/lib/Db/CoreQueryBuilder.php b/lib/Db/CoreQueryBuilder.php index 93c4da6f8..3b596bd87 100644 --- a/lib/Db/CoreQueryBuilder.php +++ b/lib/Db/CoreQueryBuilder.php @@ -59,7 +59,7 @@ class CoreQueryBuilder extends ExtendedQueryBuilder { public const TOKEN = 'u'; public const OPTIONS = 'v'; public const HELPER = 'w'; - + public const INVITATION = 'x'; public static $SQL_PATH = [ self::SINGLE => [ @@ -69,6 +69,7 @@ class CoreQueryBuilder extends ExtendedQueryBuilder { self::OPTIONS => [ ], self::MEMBER, + self::INVITATION, self::OWNER => [ self::BASED_ON ], @@ -865,6 +866,27 @@ public function leftJoinOwner(string $alias, string $field = 'unique_id'): void $this->leftJoinBasedOn($aliasMember); } + /** + * @throws RequestBuilderException + */ + public function leftJoinCircleInvitation(string $alias, string $field = 'unique_id'): void { + if ($this->getType() !== QueryBuilder::SELECT) { + return; + } + + try { + $aliasInvitation = $this->generateAlias($alias, self::INVITATION, $options); + } catch (RequestBuilderException $e) { + return; + } + + $expr = $this->expr(); + $this->generateCircleInvitationSelectAlias($aliasInvitation) + ->leftJoin( + $alias, CoreRequestBuilder::TABLE_INVITATIONS, $aliasInvitation, + $expr->eq($aliasInvitation . '.circle_id', $alias . '.' . $field), + ); + } /** * @param CircleProbe $probe @@ -1332,6 +1354,12 @@ protected function limitInitiatorVisibility(string $alias): ICompositeExpression $aliasMembershipCircle = $this->generateAlias($aliasMembership, self::CONFIG, $options); $levelCheck = [$aliasMembership]; + // no need to check anything, we are filtering by invitation code + $invitationCode = $this->get('filterInvitationCode', $options, ''); + if ($invitationCode) { + return $this->expr()->andX($this->expr()->eq('1', '1')); + } + $directMember = ''; if ($this->getBool('initiatorDirectMember', $options, false)) { $directMember = $this->generateAlias($alias, self::DIRECT_INITIATOR, $options); @@ -1547,6 +1575,22 @@ public function limitToShareOwner( } } + public function filterInvitationCode(string $alias, string $invitationCode): void { + if ($this->getType() !== QueryBuilder::SELECT) { + return; + } + + try { + $aliasInvitation = $this->generateAlias($alias, self::INVITATION, $options); + } catch (RequestBuilderException $e) { + return; + } + + $expr = $this->expr(); + $this->andWhere( + $expr->eq($aliasInvitation . '.invitation_code', $this->createNamedParameter($invitationCode)) + ); + } /** * @param string $aliasMount @@ -1635,6 +1679,17 @@ private function generateMemberSelectAlias(string $alias, array $default = []): return $this; } + private function generateCircleInvitationSelectAlias(string $alias, array $default = []): self { + $this->generateSelectAlias( + CoreRequestBuilder::$tables[CoreRequestBuilder::TABLE_INVITATIONS], + $alias, + $alias, + $default + ); + + return $this; + } + /** * @param string $alias diff --git a/lib/Db/CoreRequestBuilder.php b/lib/Db/CoreRequestBuilder.php index e02a19dca..fbcdac819 100644 --- a/lib/Db/CoreRequestBuilder.php +++ b/lib/Db/CoreRequestBuilder.php @@ -32,6 +32,7 @@ class CoreRequestBuilder { public const TABLE_STORAGES = 'storages'; public const TABLE_CIRCLE = 'circles_circle'; + public const TABLE_INVITATIONS = 'circles_invitations'; public const TABLE_MEMBER = 'circles_member'; public const TABLE_MEMBERSHIP = 'circles_membership'; public const TABLE_REMOTE = 'circles_remote'; @@ -64,6 +65,12 @@ class CoreRequestBuilder { 'contact_groupname', 'creation' ], + self::TABLE_INVITATIONS => [ + 'circle_id', + 'invitation_code', + 'created_by', + 'created', + ], self::TABLE_MEMBER => [ 'circle_id', 'member_id', diff --git a/lib/Exceptions/CircleInvitationNotFoundException.php b/lib/Exceptions/CircleInvitationNotFoundException.php new file mode 100644 index 000000000..6cbd9b3c4 --- /dev/null +++ b/lib/Exceptions/CircleInvitationNotFoundException.php @@ -0,0 +1,14 @@ +getCircle(); + + $initiatorHelper = new MemberHelper($circle->getInitiator()); + $initiatorHelper->mustBeAdmin(); + + $new = clone $circle; + + $invitationCode = $this->random->generate(16, ISecureRandom::CHAR_HUMAN_READABLE); + + $circleInvitation = new CircleInvitation(); + $circleInvitation->setCircleId($circle->getSingleId()); + $circleInvitation->setInvitationCode($invitationCode); + $circleInvitation->setCreatedBy($circle->getInitiator()->getUserId()); + + $new->setCircleInvitation($circleInvitation); + $event->getData()->sObj('circle_invitation', $circleInvitation); + + $event->setOutcome($this->serialize($new)); + } + + public function manage(FederatedEvent $event): void { + /** @var CircleInvitation $circleInvitation */ + $circleInvitation = $event->getData()->gObj('circle_invitation'); + $this->circleInvitationRequest->replace($circleInvitation); + + // todo: do we need separate event here? + $this->eventService->circleEditing($event); + } + + public function result(FederatedEvent $event, array $results): void { + // todo: do we need separate event here? + $this->eventService->circleEdited($event, $results); + } +} diff --git a/lib/FederatedItems/CircleJoin.php b/lib/FederatedItems/CircleJoin.php index 2e89056f3..27dabb2c3 100644 --- a/lib/FederatedItems/CircleJoin.php +++ b/lib/FederatedItems/CircleJoin.php @@ -23,7 +23,6 @@ use OCA\Circles\Exceptions\RequestBuilderException; use OCA\Circles\Exceptions\UnknownRemoteException; use OCA\Circles\IFederatedItem; -use OCA\Circles\IFederatedItemAsyncProcess; use OCA\Circles\IFederatedItemHighSeverity; use OCA\Circles\IFederatedItemInitiatorMembershipNotRequired; use OCA\Circles\IFederatedItemMemberCheckNotRequired; @@ -47,7 +46,6 @@ class CircleJoin implements IFederatedItem, IFederatedItemInitiatorMembershipNotRequired, - IFederatedItemAsyncProcess, IFederatedItemHighSeverity, IFederatedItemMemberCheckNotRequired, IFederatedItemMemberOptional { @@ -98,7 +96,8 @@ public function verify(FederatedEvent $event): void { $member->setInvitedBy($initiator->getInvitedBy()); } - $this->manageMemberStatus($circle, $member); + $invitationCode = $event->getParams()->g('invitationCode'); + $this->manageMemberStatus($circle, $member, $invitationCode); $this->circleService->confirmCircleNotFull($circle); @@ -219,11 +218,12 @@ public function result(FederatedEvent $event, array $results): void { /** * @param Circle $circle * @param Member $member + * @param string|null $invitationCode * * @throws FederatedItemBadRequestException * @throws RequestBuilderException */ - private function manageMemberStatus(Circle $circle, Member $member) { + private function manageMemberStatus(Circle $circle, Member $member, ?string $invitationCode = null) { try { $knownMember = $this->memberRequest->searchMember($member); if ($knownMember->getLevel() === Member::LEVEL_NONE) { @@ -245,7 +245,8 @@ private function manageMemberStatus(Circle $circle, Member $member) { throw new MemberAlreadyExistsException(StatusCode::$CIRCLE_JOIN[122], 122); } catch (MemberNotFoundException) { - if (!$circle->isConfig(Circle::CFG_OPEN)) { + $allowedToJoin = $invitationCode && $circle->getCircleInvitation()?->getInvitationCode() === $invitationCode; + if (!$circle->isConfig(Circle::CFG_OPEN) && !$allowedToJoin) { throw new FederatedItemBadRequestException(StatusCode::$CIRCLE_JOIN[124], 124); } diff --git a/lib/FederatedItems/CircleRevokeInvitation.php b/lib/FederatedItems/CircleRevokeInvitation.php new file mode 100644 index 000000000..9285ff19a --- /dev/null +++ b/lib/FederatedItems/CircleRevokeInvitation.php @@ -0,0 +1,52 @@ +getCircle(); + + $initiatorHelper = new MemberHelper($circle->getInitiator()); + $initiatorHelper->mustBeAdmin(); + + $new = clone $circle; + $new->setCircleInvitation(null); + + $event->setOutcome($this->serialize($new)); + } + + public function manage(FederatedEvent $event): void { + $circle = clone $event->getCircle(); + + $this->circleInvitationRequest->delete($circle->getSingleId()); + // todo: do we need separate event here? + $this->eventService->circleEditing($event); + } + + public function result(FederatedEvent $event, array $results): void { + // todo: do we need separate event here? + $this->eventService->circleEdited($event, $results); + } +} diff --git a/lib/Migration/Version8100Date20261129153333.php b/lib/Migration/Version8100Date20261129153333.php new file mode 100644 index 000000000..a1f6e4f8c --- /dev/null +++ b/lib/Migration/Version8100Date20261129153333.php @@ -0,0 +1,74 @@ +hasTable('circles_invitations')) { + $table = $schema->createTable('circles_invitations'); + + $table->addColumn( + 'id', 'integer', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 8, + 'unsigned' => true, + ] + ); + $table->addColumn( + 'circle_id', 'string', [ + 'length' => 32, + 'notnull' => true, + ] + ); + $table->addColumn( + 'invitation_code', 'string', [ + 'length' => 16, + 'notnull' => true, + ] + ); + $table->addColumn( + 'created_by', 'string', [ + 'length' => 255, + 'notnull' => true, + ] + ); + $table->addColumn( + 'created', 'datetime', [ + 'notnull' => true, + ] + ); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['circle_id']); + $table->addUniqueIndex(['invitation_code']); + } + + return $schema; + } +} diff --git a/lib/Model/Circle.php b/lib/Model/Circle.php index 0e1b76688..f88133f08 100644 --- a/lib/Model/Circle.php +++ b/lib/Model/Circle.php @@ -204,6 +204,9 @@ class Circle extends ManagedModel implements IEntity, IDeserializable, IQueryRow /** @var int */ private $populationInherited = 0; + /** @var CircleInvitation|null */ + private $circleInvitation = null; + // /** @var bool */ // private $hidden = false; @@ -665,6 +668,23 @@ public function getPopulationInherited(): int { return $this->populationInherited; } + /** + * @param CircleInvitation|null $circleInvitation + * + * @return Circle + */ + public function setCircleInvitation(?CircleInvitation $circleInvitation): self { + $this->circleInvitation = $circleInvitation; + + return $this; + } + + /** + * @return CircleInvitation|null + */ + public function getCircleInvitation(): ?CircleInvitation { + return $this->circleInvitation; + } /** * @param array $settings @@ -807,6 +827,13 @@ public function import(array $data): IDeserializable { } catch (InvalidItemException) { } + try { + /** @var CircleInvitation $circleInvitation */ + $circleInvitation = $this->deserialize($this->getArray('invitation', $data), CircleInvitation::class); + $this->setCircleInvitation($circleInvitation); + } catch (InvalidItemException $e) { + } + return $this; } @@ -858,6 +885,7 @@ public function jsonSerialize(): array { try { $initiatorHelper->mustBeAdmin(); $arr['settings'] = $this->getSettings(); + $arr['invitationCode'] = $this->getCircleInvitation()?->getInvitationCode(); } catch (MemberHelperException|MemberLevelException) { } } diff --git a/lib/Model/CircleInvitation.php b/lib/Model/CircleInvitation.php new file mode 100644 index 000000000..24248c5e0 --- /dev/null +++ b/lib/Model/CircleInvitation.php @@ -0,0 +1,110 @@ +circleId = $circleId; + + return $this; + } + + public function getCircleId(): string { + return $this->circleId; + } + + public function setInvitationCode(string $invitationCode): self { + $this->invitationCode = $invitationCode; + + return $this; + } + + public function getInvitationCode(): string { + return $this->invitationCode; + } + + public function setCreatedBy(string $createdBy): self { + $this->createdBy = $createdBy; + + return $this; + } + + public function getCreatedBy(): string { + return $this->createdBy; + } + + public function setCreated(int $created): self { + $this->created = $created; + + return $this; + } + + public function getCreated(): int { + return $this->created; + } + + public function import(array $data): IDeserializable { + if (!$this->get('circleId', $data)) { + throw new InvalidItemException(); + } + + $this->setCircleId($this->get('circleId', $data)) + ->setInvitationCode($this->get('invitationCode', $data)) + ->setCreatedBy($this->get('createdBy', $data)) + ->setCreated($this->getInt('created', $data)); + + return $this; + } + + + public function jsonSerialize(): array { + $arr = [ + 'circleId' => $this->getCircleId(), + 'invitationCode' => $this->getInvitationCode(), + 'createdBy' => $this->getCreatedBy(), + 'created' => $this->getCreated(), + ]; + + return $arr; + } + + + public function importFromDatabase(array $data, string $prefix = ''): IQueryRow { + if (!$this->get($prefix . 'circle_id', $data)) { + throw new CircleInvitationNotFoundException(); + } + + $this->setCircleId($this->get($prefix . 'circle_id', $data)) + ->setInvitationCode($this->get($prefix . 'invitation_code', $data)) + ->setCreatedBy($this->get($prefix . 'created_by', $data)); + + $created = $this->get($prefix . 'created', $data); + $dateTime = \DateTime::createFromFormat('Y-m-d H:i:s', $created); + $timestamp = $dateTime ? $dateTime->getTimestamp() : (int)strtotime($created); + $this->setCreated($timestamp); + + return $this; + } +} diff --git a/lib/Model/ModelManager.php b/lib/Model/ModelManager.php index c96e82b5c..4913dcbe0 100644 --- a/lib/Model/ModelManager.php +++ b/lib/Model/ModelManager.php @@ -16,6 +16,7 @@ use OCA\Circles\Db\CoreQueryBuilder; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Db\MembershipRequest; +use OCA\Circles\Exceptions\CircleInvitationNotFoundException; use OCA\Circles\Exceptions\CircleNotFoundException; use OCA\Circles\Exceptions\FederatedItemException; use OCA\Circles\Exceptions\FederatedUserNotFoundException; @@ -184,6 +185,12 @@ public function manageImportFromDatabase(ManagedModel $model, array $data, strin } } + if ($model instanceof CircleInvitation) { + if ($base === '') { + $base = CoreQueryBuilder::INVITATION; + } + } + if ($model instanceof Member) { if ($base === '') { $base = CoreQueryBuilder::MEMBER; @@ -265,6 +272,14 @@ private function importIntoCircle(Circle $circle, array $data, string $path, str } catch (MemberNotFoundException) { } break; + case CoreQueryBuilder::INVITATION: + try { + $circleInvitation = new CircleInvitation(); + $circleInvitation->importFromDatabase($data, $prefix); + $circle->setCircleInvitation($circleInvitation); + } catch (CircleInvitationNotFoundException $e) { + } + break; } } diff --git a/lib/Model/Probes/CircleProbe.php b/lib/Model/Probes/CircleProbe.php index 1c1766e37..f7165d65f 100644 --- a/lib/Model/Probes/CircleProbe.php +++ b/lib/Model/Probes/CircleProbe.php @@ -23,6 +23,7 @@ class CircleProbe extends MemberProbe { private int $filter = Circle::CFG_SINGLE; private bool $includeNonVisible = false; private bool $visitSingleCircles = false; + private ?string $invitationCode = null; private int $limitConfig = 0; /** @@ -331,6 +332,32 @@ public function isFiltered(int $config): bool { return (($this->filtered() & $config) !== 0); } + /** + * Apply a filter on the invitation code + * + * @param string $invitationCode + * + * @return $this + */ + public function filterByInvitationCode(string $invitationCode): self { + $this->invitationCode = str_replace('-', '', $invitationCode); + + return $this; + } + + /** + * @return bool + */ + public function hasInvitationCode(): bool { + return ($this->invitationCode !== null); + } + + /** + * @return string|null + */ + public function getInvitationCode(): ?string { + return $this->invitationCode; + } /** * Return an array with includes as options @@ -355,6 +382,7 @@ public function getAsOptions(): array { 'filterBackendCircles' => $this->isIncluded(Circle::CFG_BACKEND), 'filterSystemCircles' => $this->isIncluded(Circle::CFG_SYSTEM), 'filterPersonalCircles' => $this->isIncluded(Circle::CFG_PERSONAL), + 'filterInvitationCode' => $this->invitationCode, ], parent::getAsOptions() ); diff --git a/lib/Service/CircleService.php b/lib/Service/CircleService.php index 2c8f34ef9..e21f061bc 100644 --- a/lib/Service/CircleService.php +++ b/lib/Service/CircleService.php @@ -29,10 +29,12 @@ use OCA\Circles\Exceptions\UnknownRemoteException; use OCA\Circles\FederatedItems\CircleConfig; use OCA\Circles\FederatedItems\CircleCreate; +use OCA\Circles\FederatedItems\CircleCreateInvitation; use OCA\Circles\FederatedItems\CircleDestroy; use OCA\Circles\FederatedItems\CircleEdit; use OCA\Circles\FederatedItems\CircleJoin; use OCA\Circles\FederatedItems\CircleLeave; +use OCA\Circles\FederatedItems\CircleRevokeInvitation; use OCA\Circles\FederatedItems\CircleSetting; use OCA\Circles\IEntity; use OCA\Circles\IFederatedUser; @@ -296,6 +298,60 @@ public function updateSetting(string $circleId, string $setting, ?string $value) } + /** + * @param string $circleId + * + * @return array + * @throws CircleNotFoundException + * @throws FederatedEventException + * @throws FederatedItemException + * @throws InitiatorNotConfirmedException + * @throws InitiatorNotFoundException + * @throws OwnerNotFoundException + * @throws RemoteInstanceException + * @throws RemoteNotFoundException + * @throws RemoteResourceNotFoundException + * @throws RequestBuilderException + * @throws UnknownRemoteException + */ + public function createInvitation(string $circleId): array { + $circle = $this->getCircle($circleId); + + $event = new FederatedEvent(CircleCreateInvitation::class); + $event->setCircle($circle); + + $this->federatedEventService->newEvent($event); + + return $event->getOutcome(); + } + + /** + * @param string $circleId + * + * @return array + * @throws CircleNotFoundException + * @throws FederatedEventException + * @throws FederatedItemException + * @throws InitiatorNotConfirmedException + * @throws InitiatorNotFoundException + * @throws OwnerNotFoundException + * @throws RemoteInstanceException + * @throws RemoteNotFoundException + * @throws RemoteResourceNotFoundException + * @throws RequestBuilderException + * @throws UnknownRemoteException + */ + public function revokeInvitation(string $circleId): array { + $circle = $this->getCircle($circleId); + + $event = new FederatedEvent(CircleRevokeInvitation::class); + $event->setCircle($circle); + + $this->federatedEventService->newEvent($event); + + return $event->getOutcome(); + } + /** * @param string $circleId * @param string $name @@ -382,7 +438,8 @@ public function updateDescription(string $circleId, string $description): array * @throws UnknownRemoteException * @throws RequestBuilderException */ - public function circleJoin(string $circleId): array { + public function circleJoin(string $circleId, ?string $invitationCode = null): array { + $invitationCode = $invitationCode ? str_replace('-', '', $invitationCode) : null; $this->federatedUserService->mustHaveCurrentUser(); /** @var FederatedUser $currentUser */ $currentUser = $this->federatedUserService->getCurrentUser(); @@ -391,6 +448,12 @@ public function circleJoin(string $circleId): array { $probe->includeNonVisibleCircles() ->emulateVisitor(); + if ($invitationCode) { + $probe->includeSystemCircles() + ->includeHiddenCircles() + ->filterByInvitationCode($invitationCode); + } + $circle = $this->circleRequest->getCircle( $circleId, $currentUser, @@ -402,6 +465,7 @@ public function circleJoin(string $circleId): array { } $event = new FederatedEvent(CircleJoin::class); + $event->setParams(new SimpleDataStore(['invitationCode' => $invitationCode])); $event->setCircle($circle); $this->federatedEventService->newEvent($event); @@ -411,7 +475,6 @@ public function circleJoin(string $circleId): array { return $event->getOutcome(); } - /** * @param string $circleId * @param bool $force