diff --git a/appinfo/routes.php b/appinfo/routes.php index 4adb828b4..d00dff5d0 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -132,6 +132,16 @@ ['name' => 'board_api#preflighted_cors', 'url' => '/api/v{apiVersion}/{path}','verb' => 'OPTIONS', 'requirements' => ['path' => '.+']], ], 'ocs' => [ + ['name' => 'new_board#index', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'GET'], + ['name' => 'new_board#read', 'url' => '/api/v{apiVersion}/board/{boardId}', 'verb' => 'GET'], + ['name' => 'new_board#stacks', 'url' => '/api/v{apiVersion}/stacks/{boardId}', 'verb' => 'GET'], + ['name' => 'new_board#create', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'POST'], + + ['name' => 'new_card#create', 'url' => '/api/v{apiVersion}/cards', 'verb' => 'POST'], + + ['name' => 'new_stack#create', 'url' => '/api/v{apiVersion}/stacks', 'verb' => 'POST'], + ['name' => 'new_stack#delete', 'url' => '/api/v{apiVersion}/stacks/{stackId}/{boardId}', 'verb' => 'DELETE', 'defaults' => ['boardId' => null]], + ['name' => 'Config#get', 'url' => '/api/v{apiVersion}/config', 'verb' => 'GET'], ['name' => 'Config#setValue', 'url' => '/api/v{apiVersion}/config/{key}', 'verb' => 'POST'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 5b0c7bb36..370d8f566 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -27,6 +27,7 @@ use OCA\Deck\Event\CardUpdatedEvent; use OCA\Deck\Event\SessionClosedEvent; use OCA\Deck\Event\SessionCreatedEvent; +use OCA\Deck\Federation\DeckFederationProvider; use OCA\Deck\Listeners\BeforeTemplateRenderedListener; use OCA\Deck\Listeners\CommentEventListener; use OCA\Deck\Listeners\FullTextSearchEventListener; @@ -36,6 +37,7 @@ use OCA\Deck\Listeners\ResourceListener; use OCA\Deck\Middleware\DefaultBoardMiddleware; use OCA\Deck\Middleware\ExceptionMiddleware; +use OCA\Deck\Middleware\FederationMiddleware; use OCA\Deck\Notification\Notifier; use OCA\Deck\Reference\BoardReferenceProvider; use OCA\Deck\Reference\CardReferenceProvider; @@ -59,9 +61,11 @@ use OCP\Comments\CommentsEntityEvent; use OCP\Comments\CommentsEvent; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Federation\ICloudFederationProviderManager; use OCP\Group\Events\GroupDeletedEvent; use OCP\IConfig; use OCP\IDBConnection; +use OCP\Server; use OCP\Share\IManager; use OCP\User\Events\UserDeletedEvent; use OCP\Util; @@ -101,6 +105,7 @@ public function boot(IBootContext $context): void { $context->injectFn(function (Listener $listener, IEventDispatcher $eventDispatcher) { $listener->register($eventDispatcher); }); + $context->injectFn($this->registerCloudFederationProvider(...)); } public function register(IRegistrationContext $context): void { @@ -109,6 +114,7 @@ public function register(IRegistrationContext $context): void { } $context->registerCapability(Capabilities::class); + $context->registerMiddleWare(FederationMiddleware::class); $context->registerMiddleWare(ExceptionMiddleware::class); $context->registerMiddleWare(DefaultBoardMiddleware::class); @@ -189,4 +195,14 @@ protected function registerCollaborationResources(IProviderManager $resourceMana $resourceManager->registerResourceProvider(ResourceProvider::class); $resourceManager->registerResourceProvider(ResourceProviderCard::class); } + + public function registerCloudFederationProvider( + ICloudFederationProviderManager $manager, + ): void { + $manager->addCloudFederationProvider( + DeckFederationProvider::PROVIDER_ID, + "Deck Federation", + static fn () => Server::get(DeckFederationProvider::class), + ); + } } diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index 1b7326f80..600fc8d0c 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -10,6 +10,7 @@ use OCA\Deck\Db\Acl; use OCA\Deck\Db\Board; use OCA\Deck\Service\BoardService; +use OCA\Deck\Service\ExternalBoardService; use OCA\Deck\Service\Importer\BoardImportService; use OCA\Deck\Service\PermissionService; use OCP\AppFramework\ApiController; @@ -24,6 +25,7 @@ public function __construct( $appName, IRequest $request, private BoardService $boardService, + private ExternalBoardService $externalBoardService, private PermissionService $permissionService, private BoardImportService $boardImportService, private IL10N $l10n, @@ -77,8 +79,8 @@ public function getUserPermissions(int $boardId): array { * @param $participant */ #[NoAdminRequired] - public function addAcl(int $boardId, int $type, $participant, bool $permissionEdit, bool $permissionShare, bool $permissionManage): Acl { - return $this->boardService->addAcl($boardId, $type, $participant, $permissionEdit, $permissionShare, $permissionManage); + public function addAcl(int $boardId, int $type, $participant, bool $permissionEdit, bool $permissionShare, bool $permissionManage, ?string $remote = null): Acl { + return $this->boardService->addAcl($boardId, $type, $participant, $permissionEdit, $permissionShare, $permissionManage, $remote); } /** diff --git a/lib/Controller/NewBoardController.php b/lib/Controller/NewBoardController.php new file mode 100644 index 000000000..556c39e10 --- /dev/null +++ b/lib/Controller/NewBoardController.php @@ -0,0 +1,68 @@ +boardService->findAll(); + return new DataResponse($internalBoards); + } + + #[NoAdminRequired] + #[PublicPage] + #[NoCSRFRequired] + #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] + public function read(int $boardId): DataResponse { + // Board on this instance -> get it from database + $localBoard = $this->boardService->find($boardId, true, true, $this->request->getParam('accessToken')); + if($localBoard->getExternalId() !== null) { + return $this->externalBoardService->getExternalBoardFromRemote($localBoard); + } + // Board on other instance -> get it from other instance + return new DataResponse($localBoard); + } + + #[NoAdminrequired] + #[NoCSRFRequired] + public function create(string $title, string $color,): DataResponse { + return new DataResponse( $this->boardService->create($title, $this->userId, $color)); + } + + #[NoAdminRequired] + #[PublicPage] + #[NoCSRFRequired] + #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] + public function stacks(int $boardId): DataResponse{ + $localBoard = $this->boardService->find($boardId, true, true, $this->request->getParam('accessToken')); + // Board on other instance -> get it from other instance + if($localBoard->getExternalId() !== null) { + return $this->externalBoardService->getExternalStacksFromRemote($localBoard); + } else { + return new DataResponse($this->stackService->findAll($boardId)); + } + } +} diff --git a/lib/Controller/NewCardController.php b/lib/Controller/NewCardController.php new file mode 100644 index 000000000..11b554dc9 --- /dev/null +++ b/lib/Controller/NewCardController.php @@ -0,0 +1,60 @@ +boardService->find($boardId, false); + if ($board->getExternalId()) { + $card = $this->externalBoardService->createCardOnRemote($board, $title, $stackId, $type, $order, $description, $duedate, $users); + return new DataResponse($card); + } + } + + if (!$owner) { + $owner = $this->userId; + } + $card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate); + + // foreach ($labels as $label) { + // $this->assignLabel($card->getId(), $label); + // } + + // foreach ($users as $user) { + // $this->assignmentService->assignUser($card->getId(), $user['id'], $user['type']); + // } + + return new DataResponse($card); + } + +} diff --git a/lib/Controller/NewStackController.php b/lib/Controller/NewStackController.php new file mode 100644 index 000000000..ec247e70c --- /dev/null +++ b/lib/Controller/NewStackController.php @@ -0,0 +1,58 @@ +boardService->find($boardId, false); + if ($board->getExternalId()) { + $stack = $this->externalBoardService->createStackOnRemote($board, $title, $order); + return new DataResponse($stack); + } else { + $stack = $this->stackService->create($title, $boardId, $order); + return new DataResponse($stack); + }; + } + + #[NoAdminRequired] + #[PublicPage] + #[NoCSRFRequired] + #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] + public function delete(int $stackId, ?int $boardId = null) { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + $result = $this->externalBoardService->deleteStackOnRemote($board, $stackId); + return new DataResponse($result); + } + } + $result = $this->stackService->delete($stackId); + return new DataResponse($result); + } + +} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 0f846bdf6..cec7a87ac 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -10,6 +10,7 @@ use OCA\Deck\Db\Acl; use OCA\Deck\Db\CardMapper; use OCA\Deck\Service\BoardService; +use OCA\Deck\Service\ExternalBoardService; use OCA\Deck\Service\CardService; use OCA\Deck\Service\ConfigService; use OCA\Deck\Service\PermissionService; @@ -36,6 +37,7 @@ public function __construct( private PermissionService $permissionService, private IInitialState $initialState, private BoardService $boardService, + private ExternalBoardService $externalBoardService, private ConfigService $configService, private IEventDispatcher $eventDispatcher, private CardMapper $cardMapper, @@ -53,7 +55,9 @@ public function index(): TemplateResponse { $this->initialState->provideInitialState('canCreate', $this->permissionService->canCreate()); $this->initialState->provideInitialState('config', $this->configService->getAll()); - $this->initialState->provideInitialState('initialBoards', $this->boardService->findAll()); + $this->initialState->provideInitialState('initialBoards', [ + $this->boardService->findAll(), + ]); $this->eventDispatcher->dispatchTyped(new LoadSidebar()); $this->eventDispatcher->dispatchTyped(new CollaborationResourcesEvent()); diff --git a/lib/Db/Acl.php b/lib/Db/Acl.php index 77c210169..04195946b 100644 --- a/lib/Db/Acl.php +++ b/lib/Db/Acl.php @@ -19,6 +19,8 @@ * @method void setType(int $type) * @method bool isOwner() * @method void setOwner(int $owner) + * @method void setToken(string $token) + * @method string getToken() * */ class Acl extends RelationalEntity { @@ -29,6 +31,7 @@ class Acl extends RelationalEntity { public const PERMISSION_TYPE_USER = 0; public const PERMISSION_TYPE_GROUP = 1; + public const PERMISSION_TYPE_REMOTE = 6; public const PERMISSION_TYPE_CIRCLE = 7; protected $participant; @@ -38,6 +41,7 @@ class Acl extends RelationalEntity { protected $permissionShare = false; protected $permissionManage = false; protected $owner = false; + protected $token = null; public function __construct() { $this->addType('id', 'integer'); @@ -47,6 +51,7 @@ public function __construct() { $this->addType('permissionManage', 'boolean'); $this->addType('type', 'integer'); $this->addType('owner', 'boolean'); + $this->addType('token', 'string'); $this->addRelation('owner'); $this->addResolvable('participant'); } diff --git a/lib/Db/AclMapper.php b/lib/Db/AclMapper.php index 52c087dd5..aa4e2f6dd 100644 --- a/lib/Db/AclMapper.php +++ b/lib/Db/AclMapper.php @@ -18,13 +18,23 @@ public function __construct(IDBConnection $db) { parent::__construct($db, 'deck_board_acl', Acl::class); } + public function findByAccessToken(string $accessToken){ + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token') + ->from('deck_board_acl') + ->where($qb->expr()->eq('token', $qb->createNamedParameter($accessToken, IQueryBuilder::PARAM_STR))) + ->setMaxResults(1); + + return $this->findEntity($qb); + } + /** * @return Acl[] * @throws \OCP\DB\Exception */ public function findAll(int $boardId, ?int $limit = null, ?int $offset = null) { $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage') + $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token') ->from('deck_board_acl') ->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT))) ->setMaxResults($limit) diff --git a/lib/Db/Board.php b/lib/Db/Board.php index 500eb59b9..1b4018cf7 100644 --- a/lib/Db/Board.php +++ b/lib/Db/Board.php @@ -24,6 +24,10 @@ * @method void setOwner(string $owner) * @method string getColor() * @method void setColor(string $color) + * @method void setShareToken(string $shareToken) + * @method string getShareToken() + * @method void setExternalId(int $externalId) + * @method int getExternalId() */ class Board extends RelationalEntity { protected $title; @@ -41,6 +45,8 @@ class Board extends RelationalEntity { protected $activeSessions = []; protected $deletedAt = 0; protected $lastModified = 0; + protected $shareToken = null; + protected $externalId = null; protected $settings = []; @@ -50,6 +56,8 @@ public function __construct() { $this->addType('archived', 'boolean'); $this->addType('deletedAt', 'integer'); $this->addType('lastModified', 'integer'); + $this->addType('shareToken', 'string'); + $this->addType('externalId', 'integer'); $this->addRelation('labels'); $this->addRelation('acl'); $this->addRelation('shared'); diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index ebf4a672e..8de4836e7 100644 --- a/lib/Db/BoardMapper.php +++ b/lib/Db/BoardMapper.php @@ -509,6 +509,9 @@ public function mapAcl(Acl &$acl): void { } return null; } + if ($acl->getType() === Acl::PERMISSION_TYPE_REMOTE) { + return null; + } $this->logger->warning('Unknown permission type for mapping acl ' . $acl->getId()); return null; }); diff --git a/lib/Federation/DeckFederationProvider.php b/lib/Federation/DeckFederationProvider.php new file mode 100644 index 000000000..d3b4a53b9 --- /dev/null +++ b/lib/Federation/DeckFederationProvider.php @@ -0,0 +1,68 @@ +notificationManager->createNotification(); + $notification->setApp('deck'); + $notification->setUser($share->getShareWith()); + $notification->setDateTime(new \DateTime()); + $notification->setObject('remote-board-shared', (string) rand(0,9999999)); + $notification->setSubject('remote-board-shared',[$share->getResourceName(), $share->getSharedBy()]); + + $this->notificationManager->notify($notification); + + $externalBoard = new Board(); + $externalBoard->setTitle($share->getResourceName()); + $externalBoard->setExternalId($share->getProviderId()); + $externalBoard->setOwner($share->getSharedBy()); + $externalBoard->setShareToken($share->getShareSecret()); + $insertedBoard = $this->boardMapper->insert($externalBoard); + + $acl = new Acl(); + $acl->setBoardId($insertedBoard->getId()); + $acl->setType(Acl::PERMISSION_TYPE_USER); + $acl->setParticipant($share->getShareWith()); + $acl->setPermissionEdit(true); + $acl->setPermissionShare(false); + $acl->setPermissionManage(true); + $this->aclMapper->insert($acl); + + $this->changeHelper->boardChanged($insertedBoard->getId()); + return 'PLACE_HOLDER_ID'; + } + + public function notificationReceived($notificationType, $providerId, $notification): array { + return []; + } + + public function getSupportedShareTypes(): array { + return ['user']; + } +} diff --git a/lib/Federation/DeckFederationProxy.php b/lib/Federation/DeckFederationProxy.php new file mode 100644 index 000000000..7055cd95a --- /dev/null +++ b/lib/Federation/DeckFederationProxy.php @@ -0,0 +1,144 @@ + !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'), + 'nextcloud' => [ + 'allow_local_address' => $this->config->getSystemValueBool('allow_local_remote_servers'), + ], + 'headers' => [ + 'Cookie' => 'XDEBUG_SESSION=PHPSTORM', + 'Accept' => 'application/json', + 'x-nextcloud-federation' => 'true', + 'OCS-APIRequest' => 'true', + 'Accept-Language' => $this->l10nFactory->getUserLanguage($this->userSession->getUser()), + 'deck-federation-accesstoken' => $accessToken, + ], + 'timeout' => 5, + ]; + + if ($cloudId !== null && $accessToken !== null) { + } + + return $options; + } + + protected function prependProtocolIfNotAvailable(string $url): string { + if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { + $url = 'https://' . $url; + } + return $url; + } + + /** + * @param 'get'|'post'|'put'|'delete' $verb + * @throws \Exception + */ + protected function request( + string $verb, + ?string $cloudId, + #[SensitiveParameter] + ?string $accessToken, + string $url, + array $parameters, + ): IResponse { + $requestOptions = $this->generateDefaultRequestOptions($cloudId, $accessToken); + if (!empty($parameters)) { + $requestOptions['json'] = $parameters; + } + + try { + return $this->clientService->newClient()->{$verb}( + $this->prependProtocolIfNotAvailable($url), + $requestOptions + ); + } catch (ClientException $e) { + $status = $e->getResponse()->getStatusCode(); + + try { + $body = $e->getResponse()->getBody()->getContents(); + $data = json_decode($body, true, flags: JSON_THROW_ON_ERROR); + $e->getResponse()->getBody()->rewind(); + if (!is_array($data)) { + throw new \RuntimeException('JSON response is not an array'); + } + } catch (\Throwable $e) { + throw new \Exception('Error parsing JSON response', $e->getCode(), $e); + } + + $clientException = new \Exception($e->getMessage(), $status, $e); + $this->logger->debug('Client error from remote', ['exception' => $clientException]); + return new Response($e->getResponse(), false); + } catch (ServerException|\Throwable $e) { + $serverException = new \Exception($e->getMessage(), $e->getCode(), $e); + $this->logger->error('Could not reach remote', ['exception' => $serverException]); + throw $serverException; + } + } + + public function get(string $cloudId, string $shareToken, string $url, array $params = []):IResponse { + return $this->request("get", $cloudId, $shareToken, $url, $params); + } + public function post(string $cloudId, string $shareToken, string $url, array $params = []):IResponse { + return $this->request("post", $cloudId, $shareToken, $url, $params); + } + public function delete(string $cloudId, string $shareToken, string $url, array $params = []):IResponse { + return $this->request("delete", $cloudId, $shareToken, $url, $params); + } + public function getOCSData(IResponse $response, array $allowedStatusCodes = [Http::STATUS_OK]): array { + if (!in_array($response->getStatusCode(), $allowedStatusCodes, true)) { + $this->logUnexpectedStatusCode(__METHOD__, $response->getStatusCode()); + } + + try { + $content = $response->getBody(); + $responseData = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($responseData)) { + throw new \RuntimeException('JSON response is not an array'); + } + } catch (\Throwable $e) { + $this->logger->error('Error parsing JSON response: ' . ($content ?? 'no-data'), ['exception' => $e]); + throw new CannotReachRemoteException('Error parsing JSON response', $e->getCode(), $e); + } + + return $responseData['ocs']['data'] ?? []; + } + + /** + * @return Http::STATUS_BAD_REQUEST + */ + public function logUnexpectedStatusCode(string $method, int $statusCode, string $logDetails = ''): int { + if ($this->config->getSystemValueBool('debug')) { + $this->logger->error('Unexpected status code ' . $statusCode . ' returned for ' . $method . ($logDetails !== '' ? "\n" . $logDetails : '')); + } else { + $this->logger->debug('Unexpected status code ' . $statusCode . ' returned for ' . $method . ($logDetails !== '' ? "\n" . $logDetails : '')); + } + return Http::STATUS_BAD_REQUEST; + } +} diff --git a/lib/Listeners/FullTextSearchEventListener.php b/lib/Listeners/FullTextSearchEventListener.php index c6d08f4e8..45263f1d5 100644 --- a/lib/Listeners/FullTextSearchEventListener.php +++ b/lib/Listeners/FullTextSearchEventListener.php @@ -57,7 +57,7 @@ public function handle(Event $event): void { try { if ($event instanceof CardCreatedEvent) { $this->manager->createIndex( - DeckProvider::DECK_PROVIDER_ID, (string)$event->getCard()->getId(), $this->userId + DeckProvider::DECK_PROVIDER_ID, (string)$event->getCard()->getId(), $event->getCard()->getOwner() ); } if ($event instanceof CardUpdatedEvent) { diff --git a/lib/Middleware/FederationMiddleware.php b/lib/Middleware/FederationMiddleware.php new file mode 100644 index 000000000..2a7bf4855 --- /dev/null +++ b/lib/Middleware/FederationMiddleware.php @@ -0,0 +1,23 @@ +request->getHeader('deck-federation-accesstoken'); + if ($accessToken) { + $this->permissionService->setAccessToken($accessToken); + } + } +} diff --git a/lib/Migration/Version11001Date20251009165313.php b/lib/Migration/Version11001Date20251009165313.php new file mode 100644 index 000000000..9a7de8bfe --- /dev/null +++ b/lib/Migration/Version11001Date20251009165313.php @@ -0,0 +1,28 @@ +hasTable($tableName)) { + $table = $schema->getTable($tableName); + if (!$table->hasColumn('token')) { + $table->addColumn('token', 'string', [ + 'notnull' => false, + 'length' => 32, + ]); + } + } + + return $schema; + } +} diff --git a/lib/Migration/Version11001Date20251020122010.php b/lib/Migration/Version11001Date20251020122010.php new file mode 100644 index 000000000..d4cbe9d20 --- /dev/null +++ b/lib/Migration/Version11001Date20251020122010.php @@ -0,0 +1,27 @@ +hasTable('deck_boards')) { + $table = $schema->getTable('deck_boards'); + $table->addColumn('share_token', 'string', [ + 'notnull' => false, + 'length' => 32, + ]); + $table->addColumn('external_id', 'integer', [ + 'notnull' => false, + 'length' => 4, + ]); + } + return $schema; + } +} diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 7e7ea0c59..0e48d607f 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -231,6 +231,16 @@ public function prepare(INotification $notification, string $languageCode): INot ); $notification->setLink($this->getBoardUrl($boardId)); break; + case 'remote-board-shared': + $boardId = (int)$notification->getObjectId(); + if (!$boardId) { + throw new AlreadyProcessedException(); + } + $federationOwnerDisplayName = $params[1]; + $notification->setParsedSubject( + $l->t('The remote board %s has been shared with you by %s', [$params[0], $federationOwnerDisplayName]) + ); + break; } return $notification; } diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index be3092016..bdbf7eea4 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -31,6 +31,7 @@ use OCA\Deck\Event\AclUpdatedEvent; use OCA\Deck\Event\BoardUpdatedEvent; use OCA\Deck\Event\CardCreatedEvent; +use OCA\Deck\Federation\DeckFederationProvider; use OCA\Deck\NoPermissionException; use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\Validators\BoardServiceValidator; @@ -38,11 +39,16 @@ use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\DB\Exception as DbException; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Federation\ICloudFederationProvider; +use OCP\Federation\ICloudFederationProviderManager; +use OCP\Federation\ICloudFederationFactory; use OCP\IConfig; use OCP\IDBConnection; use OCP\IL10N; use OCP\IURLGenerator; +use OCP\IUserManager; use OCP\Server; +use OCP\Security\ISecureRandom; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; @@ -63,12 +69,16 @@ public function __construct( private NotificationHelper $notificationHelper, private AssignmentMapper $assignedUsersMapper, private ActivityManager $activityManager, + private readonly ICloudFederationProviderManager $cloudFederationProviderManager, + private readonly ICloudFederationFactory $federationFactory, private IEventDispatcher $eventDispatcher, private ChangeHelper $changeHelper, private IURLGenerator $urlGenerator, private IDBConnection $connection, private BoardServiceValidator $boardServiceValidator, private SessionMapper $sessionMapper, + private IUserManager $userManager, + private ISecureRandom $random, private ?string $userId, ) { } @@ -114,7 +124,7 @@ public function find(int $boardId, bool $fullDetails = true, bool $allowDeleted return $this->boardsCachePartial[$boardId]; } - $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); + $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ, null, false); $board = $this->boardMapper->find($boardId, true, true, $allowDeleted); [$board] = $this->enrichBoards([$board], $fullDetails); return $board; @@ -342,6 +352,27 @@ public function addAcl(int $boardId, int $type, $participant, bool $edit, bool $ [$edit, $share, $manage] = $this->applyPermissions($boardId, $edit, $share, $manage); $acl = new Acl(); + if ($type === Acl::PERMISSION_TYPE_REMOTE) { + $sharedBy = $this->userManager->get($this->userId); + $board = $this->find($boardId); + $token = $this->random->generate(32); + $cloudShare = $this->federationFactory->getCloudFederationShare( + $participant, // shareWith + $board->getTitle(), // name + '', // description + $boardId, // providerID + $sharedBy->getCloudId(), // owner (this instance) + $sharedBy->getDisplayName(), // ownerDisplayName (this instance) + $sharedBy->getCloudId(), // sharedBy (this instance) + $sharedBy->getDisplayName(), // sharedByDisplayName (this instance) + $token, // sharedSecret + 'user', // shareType + 'deck' // resourceType + ); + $resp = $this->cloudFederationProviderManager->sendCloudShare($cloudShare); + $acl->setToken($token); + } + $acl->setBoardId($boardId); $acl->setType($type); $acl->setParticipant($participant); diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index f5995874f..677a0a9e0 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -193,7 +193,7 @@ public function create(string $title, int $stackId, string $type, int $order, st $card->setDuedate($duedate); $card = $this->cardMapper->insert($card); - $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_CREATE); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_CREATE, [], $card->getOwner()); $this->changeHelper->cardChanged($card->getId(), false); $this->eventDispatcher->dispatchTyped(new CardCreatedEvent($card)); diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php new file mode 100644 index 000000000..570b433db --- /dev/null +++ b/lib/Service/ExternalBoardService.php @@ -0,0 +1,108 @@ +getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . "/ocs/v2.php/apps/deck/api/v1.0/board/".$localBoard->getExternalId(); + $resp = $this->proxy->get($participantCloudId->getId(), $shareToken, $url); + return new DataResponse($this->LocalizeRemoteBoard($this->proxy->getOcsData($resp), $localBoard)); + } + public function getExternalStacksFromRemote(Board $localBoard):DataResponse { + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . "/ocs/v2.php/apps/deck/api/v1.0/stacks/".$localBoard->getExternalId(); + $resp = $this->proxy->get($participantCloudId->getId(), $shareToken, $url); + return new DataResponse($this->LocalizeRemoteStacks($this->proxy->getOcsData($resp), $localBoard)); + } + + public function LocalizeRemoteStacks(array $stacks, Board $localBoard) { + foreach ($stacks as $i => $stack) { + $stack['boardId'] = $localBoard->getId(); + $stacks[$i] = $stack; + } + return $stacks; + } + public function LocalizeRemoteBoard(array $remoteBoard, Board $localBoard) { + $remoteBoard['id'] = $localBoard->getId(); + $remoteBoard['stacks'] = $this->LocalizeRemoteStacks($remoteBoard['stacks'], $localBoard); + return $remoteBoard; + } + + public function createCardOnRemote( + Board $localBoard, + string $title, + int $stackId, + ?string $type = 'plain', + ?int $order = 999, + ?string $description = '', + $duedate = null, + ?array $users = [], + ?int $boardId = null + ): array { + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . "/ocs/v2.php/apps/deck/api/v1.0/cards"; + $params = [ + 'title' => $title, + 'stackId' => $stackId, + 'type' => $type, + 'order' => $order, + 'owner' => $participantCloudId->getId(), + 'description' => $description, + 'duedate' => $duedate, + 'users' => $users, + 'boardId' => $localBoard->getExternalId(), + ]; + $resp = $this->proxy->post($participantCloudId->getId(), $shareToken, $url, $params); + return $this->proxy->getOcsData($resp); + } + + public function createStackOnRemote( + Board $localBoard, + string $title, + int $order = 0, + ): array { + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . "/ocs/v2.php/apps/deck/api/v1.0/stacks"; + $params = [ + 'title' => $title, + 'boardId' => $localBoard->getExternalId(), + 'order' => $order, + ]; + $resp = $this->proxy->post($participantCloudId->getId(), $shareToken, $url, $params); + $stack = $this->proxy->getOcsData($resp); + return $this->localizeRemoteStacks([$stack], $localBoard)[0]; + } + + public function deleteStackOnRemote(Board $localBoard, int $stackId): array { + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . "/ocs/v2.php/apps/deck/api/v1.0/stacks/" . $stackId; + $resp = $this->proxy->delete($participantCloudId->getId(), $shareToken, $url, []); + return $this->proxy->getOcsData($resp); + } +} diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 0f8831daa..87e56be3c 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -28,6 +28,9 @@ class PermissionService { private array $users = []; + // accessToken to check permission for federated shares + private ?string $accessToken = null; + private CappedMemoryCache $boardCache; /** @var CappedMemoryCache> */ private CappedMemoryCache $permissionCache; @@ -47,6 +50,13 @@ public function __construct( $this->permissionCache = new CappedMemoryCache(); } + /** + * Set AccessToken for requests from federation shares + */ + public function setAccessToken(string $token) { + $this->accessToken = $token; + } + /** * Get current user permissions for a board by id * @@ -57,21 +67,34 @@ public function getPermissions(int $boardId, ?string $userId = null): array { $userId = $this->userId; } - $cacheKey = $boardId . '-' . $userId; - if ($cached = $this->permissionCache->get($cacheKey)) { - /** @var array $cached */ - return $cached; + // check chache if not a federated request + if ($this->accessToken === null) { + $cacheKey = $boardId . '-' . $userId; + if ($cached = $this->permissionCache->get($cacheKey)) { + /** @var array $cached */ + return $cached; + } } + $board = $this->getBoard($boardId); + if ($this->accessToken !== null) { + $acls = $board->getDeletedAt() === 0 ? $this->aclMapper->findAll($boardId) : []; + $permissions = [ + Acl::PERMISSION_READ => $this->externalUserCan($acls, Acl::PERMISSION_READ, $this->accessToken), + Acl::PERMISSION_EDIT => $this->externalUserCan($acls, Acl::PERMISSION_EDIT, $this->accessToken), + Acl::PERMISSION_MANAGE => $this->externalUserCan($acls, Acl::PERMISSION_MANAGE, $this->accessToken), + Acl::PERMISSION_SHARE => $this->externalUserCan($acls, Acl::PERMISSION_SHARE, $this->accessToken) + ]; + return $permissions; + } + try { - $board = $this->getBoard($boardId); $owner = $this->userIsBoardOwner($boardId, $userId); $acls = $board->getDeletedAt() === 0 ? $this->aclMapper->findAll($boardId) : []; } catch (MultipleObjectsReturnedException|DoesNotExistException $e) { $owner = false; $acls = []; } - $permissions = [ Acl::PERMISSION_READ => $owner || $this->userCan($acls, Acl::PERMISSION_READ, $userId), Acl::PERMISSION_EDIT => $owner || $this->userCan($acls, Acl::PERMISSION_EDIT, $userId), @@ -93,6 +116,14 @@ public function getPermissions(int $boardId, ?string $userId = null): array { public function matchPermissions(Board $board) { $owner = $this->userIsBoardOwner($board->getId()); $acls = $board->getAcl() ?? []; + if ($this->accessToken !== null) { + return [ + Acl::PERMISSION_READ => $this->externalUserCan($acls, Acl::PERMISSION_READ, $this->accessToken), + Acl::PERMISSION_EDIT => $this->externalUserCan($acls, Acl::PERMISSION_EDIT, $this->accessToken), + Acl::PERMISSION_MANAGE => $this->externalUserCan($acls, Acl::PERMISSION_MANAGE, $this->accessToken), + Acl::PERMISSION_SHARE => $this->externalUserCan($acls, Acl::PERMISSION_SHARE, $this->accessToken) + ]; + } return [ Acl::PERMISSION_READ => $owner || $this->userCan($acls, Acl::PERMISSION_READ), Acl::PERMISSION_EDIT => $owner || $this->userCan($acls, Acl::PERMISSION_EDIT), @@ -107,7 +138,7 @@ public function matchPermissions(Board $board) { * * @throws NoPermissionException */ - public function checkPermission(?IPermissionMapper $mapper, $id, int $permission, $userId = null, bool $allowDeletedCard = false): bool { + public function checkPermission(?IPermissionMapper $mapper, $id, int $permission, $userId = null, bool $allowDeletedCard = false): string { $boardId = (int)$id; if ($mapper instanceof IPermissionMapper && !($mapper instanceof BoardMapper)) { $boardId = $mapper->findBoardId($id); @@ -165,6 +196,18 @@ private function getBoard(int $boardId): Board { return $this->boardCache[(string)$boardId]; } + + public function externalUserCan(array $acls, $permission, $shareToken = null) { + foreach($acls as $acl) { + if ($acl->getType() === Acl::PERMISSION_TYPE_REMOTE) { + $token = $acl->getToken(); + if ($acl->getToken() === $shareToken) { + return $acl->getPermission($permission); + } + } + } + } + /** * Check if permission matches the acl rules for current user and groups * @@ -202,6 +245,10 @@ public function userCan(array $acls, $permission, $userId = null) { return $hasGroupPermission; } + public function getUserId() { + return $this->userId || $this->aclMapper->findByAccessToken($this->accessToken)->getParticipant(); + } + /** * Find a list of all users (including the ones from groups) * Required to allow assigning them to cards diff --git a/lib/Service/StackService.php b/lib/Service/StackService.php index 631c4b184..c2aed66e5 100644 --- a/lib/Service/StackService.php +++ b/lib/Service/StackService.php @@ -202,7 +202,7 @@ public function create(string $title, int $boardId, int $order): Stack { $stack->setOrder($order); $stack = $this->stackMapper->insert($stack); $this->activityManager->triggerEvent( - ActivityManager::DECK_OBJECT_BOARD, $stack, ActivityManager::SUBJECT_STACK_CREATE + ActivityManager::DECK_OBJECT_BOARD, $stack, ActivityManager::SUBJECT_STACK_CREATE, [], $this->permissionService->getUserId() ); $this->changeHelper->boardChanged($boardId); $this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($boardId)); @@ -225,7 +225,7 @@ public function delete(int $id): Stack { $stack = $this->stackMapper->update($stack); $this->activityManager->triggerEvent( - ActivityManager::DECK_OBJECT_BOARD, $stack, ActivityManager::SUBJECT_STACK_DELETE + ActivityManager::DECK_OBJECT_BOARD, $stack, ActivityManager::SUBJECT_STACK_DELETE, [], $this->permissionService->getUserId() ); $this->changeHelper->boardChanged($stack->getBoardId()); $this->eventDispatcher->dispatchTyped(new BoardUpdatedEvent($stack->getBoardId())); diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index e583464b8..d01a1d8bc 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -39,10 +39,12 @@
+
- {{ acl.participant.displayname }} + {{ acl.participant.displayname || acl.participant }} {{ t('deck', '(Group)') }} {{ t('deck', '(Team)') }} + {{ t('deck', '(remote)') }} { const foundIndex = this.board.acl.findIndex((acl) => { return acl.participant.uid === sharee.value.shareWith && acl.participant.type === sharee.value.shareType @@ -191,6 +194,7 @@ export default { loading(false) }, async clickAddAcl() { + console.log(this.addAcl) this.addAclForAPI = { type: this.addAcl.value.shareType, participant: this.addAcl.value.shareWith, diff --git a/src/components/navigation/AppNavigation.vue b/src/components/navigation/AppNavigation.vue index d6f028c9e..c3421fd03 100644 --- a/src/components/navigation/AppNavigation.vue +++ b/src/components/navigation/AppNavigation.vue @@ -122,6 +122,7 @@ import ShareVariantIcon from 'vue-material-design-icons/ShareOutline.vue' import HelpModal from './../modals/HelpModal.vue' import { subscribe } from '@nextcloud/event-bus' import AppNavigationImportBoard from './AppNavigationImportBoard.vue' +import { mapState } from 'vuex/dist/vuex.common.js' const canCreateState = loadState('deck', 'canCreate') diff --git a/src/components/navigation/AppNavigationBoard.vue b/src/components/navigation/AppNavigationBoard.vue index 14562390c..3c8cab09b 100644 --- a/src/components/navigation/AppNavigationBoard.vue +++ b/src/components/navigation/AppNavigationBoard.vue @@ -22,7 +22,7 @@ @@ -103,7 +103,7 @@ {{ t('deck', 'No notifications') }} - @@ -234,7 +234,7 @@ export default { } }, canManage() { - return this.board.permissions.PERMISSION_MANAGE + return this.board.permissions?.PERMISSION_MANAGE }, dueDateReminderIcon() { if (this.board.settings['notify-due'] === 'all') { diff --git a/src/components/navigation/AppNavigationBoardCategory.vue b/src/components/navigation/AppNavigationBoardCategory.vue index f0f56433d..348a3a577 100644 --- a/src/components/navigation/AppNavigationBoardCategory.vue +++ b/src/components/navigation/AppNavigationBoardCategory.vue @@ -63,6 +63,9 @@ export default { }, computed: { boardsSorted() { + console.log("FROM NAVIGATION") + console.log(this.id) + console.log(this.boards) return [...this.boards].sort((a, b) => a.title.localeCompare(b.title)) }, collapsible() { diff --git a/src/services/BoardApi.js b/src/services/BoardApi.js index 7a6ae24ff..0b2f209fb 100644 --- a/src/services/BoardApi.js +++ b/src/services/BoardApi.js @@ -4,7 +4,7 @@ */ import axios from '@nextcloud/axios' -import { generateUrl } from '@nextcloud/router' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' import '../models/index.js' /** @@ -17,6 +17,11 @@ export class BoardApi { return generateUrl(url) } + ocsUrl(url) { + url = `/apps/deck/api/v1.0${url}` + return generateOcsUrl(url) + } + /** * Updates a board. * @@ -49,10 +54,10 @@ export class BoardApi { * @return {Promise} */ createBoard(boardData) { - return axios.post(this.url('/boards'), boardData) + return axios.post(this.ocsUrl('/boards'), boardData) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) @@ -109,10 +114,10 @@ export class BoardApi { } loadById(id) { - return axios.get(this.url(`/boards/${id}`)) + return axios.get(this.ocsUrl(`/board/${id}`)) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) @@ -304,6 +309,7 @@ export class BoardApi { // Acl API Calls addAcl(acl) { + console.log(acl.participant) return axios.post(this.url(`/boards/${acl.boardId}/acl`), acl) .then( (response) => { diff --git a/src/services/CardApi.js b/src/services/CardApi.js index 15031aa1d..7171d8c73 100644 --- a/src/services/CardApi.js +++ b/src/services/CardApi.js @@ -13,11 +13,16 @@ export class CardApi { return generateUrl(url) } + ocsUrl(url) { + url = `/apps/deck/api/v1.0${url}` + return generateOcsUrl(url) + } + addCard(card) { - return axios.post(this.url('/cards'), card) + return axios.post(this.ocsUrl('/cards'), card) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) diff --git a/src/services/StackApi.js b/src/services/StackApi.js index 890474345..3e09687bc 100644 --- a/src/services/StackApi.js +++ b/src/services/StackApi.js @@ -4,7 +4,7 @@ */ import axios from '@nextcloud/axios' -import { generateUrl } from '@nextcloud/router' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' import '../models/index.js' export class StackApi { @@ -13,12 +13,16 @@ export class StackApi { url = `/apps/deck${url}` return generateUrl(url) } + ocsUrl(url) { + url = `/apps/deck/api/v1.0${url}` + return generateOcsUrl(url) + } loadStacks(boardId) { - return axios.get(this.url(`/stacks/${boardId}`)) + return axios.get(this.ocsUrl(`/stacks/${boardId}`)) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) @@ -64,10 +68,10 @@ export class StackApi { * @return {Promise} */ createStack(stack) { - return axios.post(this.url('/stacks'), stack) + return axios.post(this.ocsUrl('/stacks'), stack) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) @@ -93,11 +97,11 @@ export class StackApi { }) } - deleteStack(stackId) { - return axios.delete(this.url(`/stacks/${stackId}`)) + deleteStack(stackId, boardId) { + return axios.delete(this.ocsUrl(`/stacks/${stackId}/${boardId}`)) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) diff --git a/src/store/main.js b/src/store/main.js index b7e3122c5..e0d5c15a2 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -51,7 +51,7 @@ export default new Vuex.Store({ currentBoard: null, currentCard: null, hasCardSaveError: false, - boards: loadState('deck', 'initialBoards', []), + boards: loadState('deck', 'initialBoards', {}), sharees: [], assignableUsers: [], boardFilter: BOARD_FILTERS.ALL, @@ -241,6 +241,7 @@ export default new Vuex.Store({ state.sharees.push(...shareesUsersAndGroups.users) state.sharees.push(...shareesUsersAndGroups.groups) state.sharees.push(...shareesUsersAndGroups.circles) + state.sharees.push(...shareesUsersAndGroups.remotes) }, setAssignableUsers(state, users) { state.assignableUsers = users @@ -436,7 +437,7 @@ export default new Vuex.Store({ params.append('search', query) params.append('format', 'json') params.append('perPage', 20) - params.append('itemType', [0, 1, 4, 7]) + params.append('itemType', 'deck') params.append('lookup', false) const response = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees'), { params }) diff --git a/src/store/stack.js b/src/store/stack.js index fc7074b07..022d5fab2 100644 --- a/src/store/stack.js +++ b/src/store/stack.js @@ -101,7 +101,7 @@ export default { }) }, deleteStack({ commit }, stack) { - apiClient.deleteStack(stack.id) + apiClient.deleteStack(stack.id, stack.boardId) .then((stack) => { commit('deleteStack', stack) commit('moveStackToTrash', stack)