From de49bffd9ad9a5585a0984c1a8d9fc71bf617d24 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sat, 29 Nov 2025 23:38:19 +0100 Subject: [PATCH 1/2] feat: Add the possibility to create invitation links to circles Signed-off-by: Kostiantyn Miakshyn --- appinfo/routes.php | 2 + lib/Controller/LocalController.php | 41 ++++ lib/Db/CircleInvitationRequest.php | 55 ++++++ lib/Db/CircleRequest.php | 2 + lib/Db/CircleRequestBuilder.php | 23 ++- lib/Db/CoreQueryBuilder.php | 45 ++++- lib/Db/CoreRequestBuilder.php | 7 + .../CircleInvitationNotFoundException.php | 14 ++ lib/FederatedItems/CircleCreateInvitation.php | 90 +++++++++ lib/FederatedItems/CircleRevokeInvitation.php | 74 ++++++++ .../Version8100Date20261129153333.php | 74 ++++++++ lib/Model/Circle.php | 29 ++- lib/Model/CircleInvitation.php | 178 ++++++++++++++++++ lib/Model/ModelManager.php | 15 ++ lib/Service/CircleService.php | 98 ++++++++++ 15 files changed, 743 insertions(+), 4 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/routes.php b/appinfo/routes.php index fb346ddff..438d82bcf 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -38,6 +38,8 @@ ['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#editConfig', 'url' => '/circles/{circleId}/config', 'verb' => 'PUT'], ['name' => 'Local#link', 'url' => '/link/{circleId}/{singleId}', 'verb' => 'GET'], diff --git a/lib/Controller/LocalController.php b/lib/Controller/LocalController.php index 8a1e8f08f..e05b39594 100644 --- a/lib/Controller/LocalController.php +++ b/lib/Controller/LocalController.php @@ -590,6 +590,47 @@ public function link(string $circleId, string $singleId): DataResponse { } } + /** + * @NoAdminRequired + * + * @param string $circleId + * + * @return DataResponse + * @throws OCSException + */ + 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 + * + * @param string $circleId + * + * @return DataResponse + * @throws OCSException + */ + 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); + } + } /** * @return void diff --git a/lib/Db/CircleInvitationRequest.php b/lib/Db/CircleInvitationRequest.php new file mode 100644 index 000000000..b95bee682 --- /dev/null +++ b/lib/Db/CircleInvitationRequest.php @@ -0,0 +1,55 @@ +confirmValidId($circleInvitation->getCircleId()); + + $qb = $this->getCircleInvitationInsertSql(); + $qb->setValue('circle_id', $qb->createNamedParameter($circleInvitation->getCircleId())) + ->setValue('invitation_code', $qb->createNamedParameter($circleInvitation->getInvitationCode())) + ->setValue('created_by', $qb->createNamedParameter($circleInvitation->getCreatedBy())); + $qb->executeStatement(); + } + + /** + * @param CircleInvitation $circleInvitation + * + * @throws InvalidIdException + */ + public function replace(CircleInvitation $circleInvitation): void { + $this->delete($circleInvitation->getCircleId()); + $this->save($circleInvitation); + } + + /** + * @param string $circleId + */ + public function delete(string $circleId): void { + $qb = $this->getCircleInvitationDeleteSql(); + $qb->limitToCircleId($circleId); + + $qb->executeStatement(); + } +} diff --git a/lib/Db/CircleRequest.php b/lib/Db/CircleRequest.php index 25496b9d9..d84962418 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()); @@ -369,6 +370,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 644ba62c7..3a4584781 100644 --- a/lib/Db/CircleRequestBuilder.php +++ b/lib/Db/CircleRequestBuilder.php @@ -33,6 +33,16 @@ protected function getCircleInsertSql(): CoreQueryBuilder { return $qb; } + /** + * @return CoreQueryBuilder&IQueryBuilder + */ + protected function getCircleInvitationInsertSql(): CoreQueryBuilder { + $qb = $this->getQueryBuilder(); + $qb->insert(self::TABLE_INVITATIONS) + ->setValue('created', $qb->createNamedParameter($this->timezoneService->getUTCDate())); + + return $qb; + } /** * @return CoreQueryBuilder&IQueryBuilder @@ -44,7 +54,6 @@ protected function getCircleUpdateSql(): CoreQueryBuilder { return $qb; } - /** * @param string $alias * @param bool $single @@ -65,7 +74,6 @@ protected function getCircleSelectSql( return $qb; } - /** * Base of the Sql Delete request * @@ -78,6 +86,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 0b3ac9314..15a6e3c95 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 ], @@ -839,6 +840,31 @@ public function leftJoinOwner(string $alias, string $field = 'unique_id'): void $this->leftJoinBasedOn($aliasMember); } + /** + * @param string $alias + * @param string $field + * + * @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); + $getData = $this->getBool('getData', $options, false); + } 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 @@ -1584,6 +1610,23 @@ private function generateMemberSelectAlias(string $alias, array $default = []): return $this; } + /** + * @param string $alias + * @param 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 2d49b1ef6..c56f5edff 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, + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + ); + + $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)); + } + + /** + * @param FederatedEvent $event + * + * @throws RequestBuilderException + */ + 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); + } + + /** + * @param FederatedEvent $event + * @param array $results + */ + 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/CircleRevokeInvitation.php b/lib/FederatedItems/CircleRevokeInvitation.php new file mode 100644 index 000000000..8a0c43f95 --- /dev/null +++ b/lib/FederatedItems/CircleRevokeInvitation.php @@ -0,0 +1,74 @@ +getCircle(); + + $initiatorHelper = new MemberHelper($circle->getInitiator()); + $initiatorHelper->mustBeAdmin(); + + $new = clone $circle; + $new->setCircleInvitation(null); + + $event->setOutcome($this->serialize($new)); + } + + /** + * @param FederatedEvent $event + * + * @throws RequestBuilderException + */ + 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); + } + + /** + * @param FederatedEvent $event + * @param array $results + */ + 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 7f0d53ce7..7424c56d6 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; @@ -666,6 +669,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 @@ -808,6 +828,13 @@ public function import(array $data): IDeserializable { } catch (InvalidItemException $e) { } + 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 $e) { } } @@ -900,7 +928,6 @@ public function importFromDatabase(array $data, string $prefix = ''): IQueryRow $this->getManager()->manageImportFromDatabase($this, $data, $prefix); - // TODO: deprecated in NC27, remove those (17) lines that was needed to finalise migration to 24 // if password is not hashed (pre-22), hash it and update new settings in DB $curr = $this->get('password_single', $this->getSettings()); diff --git a/lib/Model/CircleInvitation.php b/lib/Model/CircleInvitation.php new file mode 100644 index 000000000..71157ec10 --- /dev/null +++ b/lib/Model/CircleInvitation.php @@ -0,0 +1,178 @@ +circleId = $circleId; + + return $this; + } + + /** + * @return string + */ + public function getCircleId(): string { + return $this->circleId; + } + + /** + * @param string $invitationCode + * + * @return self + */ + public function setInvitationCode(string $invitationCode): self { + $this->invitationCode = $invitationCode; + + return $this; + } + + /** + * @return string + */ + public function getInvitationCode(): string { + return $this->invitationCode; + } + + /** + * @param string $createdBy + * + * @return self + */ + public function setCreatedBy(string $createdBy): self { + $this->createdBy = $createdBy; + + return $this; + } + + /** + * @return string + */ + public function getCreatedBy(): string { + return $this->createdBy; + } + + /** + * @param int $created + * + * @return self + */ + public function setCreated(int $created): self { + $this->created = $created; + + return $this; + } + + /** + * @return int + */ + public function getCreated(): int { + return $this->created; + } + + /** + * @param array $data + * + * @return $this + * @throws InvalidItemException + */ + 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; + } + + + /** + * @return array + * @throws FederatedItemException + * @throws RemoteInstanceException + * @throws RemoteNotFoundException + * @throws RemoteResourceNotFoundException + * @throws RequestBuilderException + * @throws UnknownRemoteException + */ + public function jsonSerialize(): array { + $arr = [ + 'circleId' => $this->getCircleId(), + 'invitationCode' => $this->getInvitationCode(), + 'createdBy' => $this->getCreatedBy(), + 'created' => $this->getCreated(), + ]; + + return $arr; + } + + + /** + * @param array $data + * @param string $prefix + * + * @return IQueryRow + * @throws CircleNotFoundException + */ + 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 721c6683c..42df40f71 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; @@ -207,6 +208,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; @@ -288,6 +295,14 @@ private function importIntoCircle(Circle $circle, array $data, string $path, str } catch (MemberNotFoundException $e) { } break; + case CoreQueryBuilder::INVITATION: + try { + $circleInvitation = new CircleInvitation(); + $circleInvitation->importFromDatabase($data, $prefix); + $circle->setCircleInvitation($circleInvitation); + } catch (CircleInvitationNotFoundException $e) { + } + break; } } diff --git a/lib/Service/CircleService.php b/lib/Service/CircleService.php index cf45159e3..82c096032 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; @@ -334,6 +336,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 @@ -445,6 +501,48 @@ public function circleJoin(string $circleId): array { 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 UnknownRemoteException + * @throws RequestBuilderException + */ + public function circleJoinByInvitationCode(string $circleId, string $invitationCode): array { + // fixme: implement + $this->federatedUserService->mustHaveCurrentUser(); + + $probe = new CircleProbe(); + $probe->includeNonVisibleCircles() + ->emulateVisitor(); + + $circle = $this->circleRequest->getCircle( + $circleId, + $this->federatedUserService->getCurrentUser(), + $probe + ); + + if (!$circle->getInitiator()->hasInvitedBy()) { + $this->federatedUserService->setMemberPatron($circle->getInitiator()); + } + + $event = new FederatedEvent(CircleJoin::class); + $event->setCircle($circle); + + $this->federatedEventService->newEvent($event); + + return $event->getOutcome(); + } + /** * @param string $circleId From af15456bc5d4b2d7362f7f8edc9a3b46bec40a71 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sun, 30 Nov 2025 22:26:19 +0100 Subject: [PATCH 2/2] feat: Add the possibility to create invitation links to circles Signed-off-by: Kostiantyn Miakshyn --- appinfo/routes.php | 2 + lib/Controller/LocalController.php | 84 ++++++++++++++++++++++++++++-- lib/Db/CircleRequest.php | 3 ++ lib/Db/CoreQueryBuilder.php | 28 +++++++++- lib/FederatedItems/CircleJoin.php | 9 ++-- lib/Model/Probes/CircleProbe.php | 28 ++++++++++ lib/Service/CircleService.php | 48 +++-------------- 7 files changed, 152 insertions(+), 50 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 438d82bcf..8836768f6 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -40,6 +40,8 @@ ['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#link', 'url' => '/link/{circleId}/{singleId}', 'verb' => 'GET'], diff --git a/lib/Controller/LocalController.php b/lib/Controller/LocalController.php index e05b39594..3621c05a6 100644 --- a/lib/Controller/LocalController.php +++ b/lib/Controller/LocalController.php @@ -32,6 +32,9 @@ use OCA\Circles\Service\SearchService; use OCA\Circles\Tools\Traits\TDeserialize; use OCA\Circles\Tools\Traits\TNCLogger; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSException; use OCP\AppFramework\OCSController; @@ -591,13 +594,12 @@ public function link(string $circleId, string $singleId): DataResponse { } /** - * @NoAdminRequired - * * @param string $circleId * * @return DataResponse * @throws OCSException */ + #[NoAdminRequired] public function createInvitation(string $circleId): DataResponse { try { $this->setCurrentFederatedUser(); @@ -612,13 +614,12 @@ public function createInvitation(string $circleId): DataResponse { } /** - * @NoAdminRequired - * * @param string $circleId * * @return DataResponse * @throws OCSException */ + #[NoAdminRequired] public function revokeInvitation(string $circleId): DataResponse { try { $this->setCurrentFederatedUser(); @@ -632,6 +633,81 @@ public function revokeInvitation(string $circleId): DataResponse { } } + /** + * @param string $invitationCode + * + * @return DataResponse + * @throws OCSException + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 10, period: 3600)] + 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); + } + } + + /** + * @param string $invitationCode + * + * @return DataResponse + * @throws OCSException + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 10, period: 3600)] + 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); + } + } + /** * @return void * @throws FederatedUserException diff --git a/lib/Db/CircleRequest.php b/lib/Db/CircleRequest.php index d84962418..f399c47f6 100644 --- a/lib/Db/CircleRequest.php +++ b/lib/Db/CircleRequest.php @@ -175,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); } diff --git a/lib/Db/CoreQueryBuilder.php b/lib/Db/CoreQueryBuilder.php index 15a6e3c95..72263c4f7 100644 --- a/lib/Db/CoreQueryBuilder.php +++ b/lib/Db/CoreQueryBuilder.php @@ -853,7 +853,6 @@ public function leftJoinCircleInvitation(string $alias, string $field = 'unique_ try { $aliasInvitation = $this->generateAlias($alias, self::INVITATION, $options); - $getData = $this->getBool('getData', $options, false); } catch (RequestBuilderException $e) { return; } @@ -1334,6 +1333,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); @@ -1548,6 +1553,27 @@ public function limitToShareOwner( } } + /** + * filter circle by invitation code + * + * @param string $invitationCode + */ + 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 diff --git a/lib/FederatedItems/CircleJoin.php b/lib/FederatedItems/CircleJoin.php index f1bb26970..37c3ecd28 100644 --- a/lib/FederatedItems/CircleJoin.php +++ b/lib/FederatedItems/CircleJoin.php @@ -130,7 +130,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); @@ -251,11 +252,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) { @@ -277,7 +279,8 @@ private function manageMemberStatus(Circle $circle, Member $member) { throw new MemberAlreadyExistsException(StatusCode::$CIRCLE_JOIN[122], 122); } catch (MemberNotFoundException $e) { - 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/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 82c096032..f1d3cb308 100644 --- a/lib/Service/CircleService.php +++ b/lib/Service/CircleService.php @@ -476,55 +476,19 @@ 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(); $probe = new CircleProbe(); $probe->includeNonVisibleCircles() ->emulateVisitor(); - $circle = $this->circleRequest->getCircle( - $circleId, - $this->federatedUserService->getCurrentUser(), - $probe - ); - - if (!$circle->getInitiator()->hasInvitedBy()) { - $this->federatedUserService->setMemberPatron($circle->getInitiator()); + if ($invitationCode) { + $probe->includeHiddenCircles() + ->filterByInvitationCode($invitationCode); } - $event = new FederatedEvent(CircleJoin::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 UnknownRemoteException - * @throws RequestBuilderException - */ - public function circleJoinByInvitationCode(string $circleId, string $invitationCode): array { - // fixme: implement - $this->federatedUserService->mustHaveCurrentUser(); - - $probe = new CircleProbe(); - $probe->includeNonVisibleCircles() - ->emulateVisitor(); - $circle = $this->circleRequest->getCircle( $circleId, $this->federatedUserService->getCurrentUser(), @@ -536,6 +500,7 @@ public function circleJoinByInvitationCode(string $circleId, string $invitationC } $event = new FederatedEvent(CircleJoin::class); + $event->setParams(new SimpleDataStore(['invitationCode' => $invitationCode])); $event->setCircle($circle); $this->federatedEventService->newEvent($event); @@ -543,7 +508,6 @@ public function circleJoinByInvitationCode(string $circleId, string $invitationC return $event->getOutcome(); } - /** * @param string $circleId * @param bool $force