From 6d04f589daf25fc61b8042805313c0c7bb5b26d7 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Mon, 6 Oct 2025 16:08:29 +0200 Subject: [PATCH 1/8] feat: initiate remote share notifications Signed-off-by: grnd-alt --- lib/AppInfo/Application.php | 14 ++++++ lib/Controller/BoardController.php | 4 +- lib/Db/Acl.php | 5 +++ lib/Db/BoardMapper.php | 3 ++ lib/Federation/DeckFederationProvider.php | 44 +++++++++++++++++++ .../Version11001Date20251009165313.php | 28 ++++++++++++ lib/Notification/Notifier.php | 10 +++++ lib/Service/BoardService.php | 31 +++++++++++++ src/components/board/SharingTabSidebar.vue | 6 ++- src/services/BoardApi.js | 1 + src/store/main.js | 3 +- 11 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 lib/Federation/DeckFederationProvider.php create mode 100644 lib/Migration/Version11001Date20251009165313.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 5b0c7bb36..2894d92a1 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; @@ -59,9 +60,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 +104,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 { @@ -189,4 +193,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..864407438 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -77,8 +77,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/Db/Acl.php b/lib/Db/Acl.php index 77c210169..49fd6b442 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(string $token) * */ 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/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..bb3c6ab1a --- /dev/null +++ b/lib/Federation/DeckFederationProvider.php @@ -0,0 +1,44 @@ +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->getDescription(), $share->getSharedBy()]); + + $this->notificationManager->notify($notification); + + return 'PLACE_HOLDER_ID'; + } + + public function notificationReceived($notificationType, $providerId, $notification): array { + return []; + } + + public function getSupportedShareTypes(): array { + return ['user']; + } +} 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/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..0f3dde70f 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, ) { } @@ -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 + $boardId, // name + $board->getTitle(), // description + DeckFederationProvider::PROVIDER_ID, // 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/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/services/BoardApi.js b/src/services/BoardApi.js index 7a6ae24ff..7dd96e91c 100644 --- a/src/services/BoardApi.js +++ b/src/services/BoardApi.js @@ -304,6 +304,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/store/main.js b/src/store/main.js index b7e3122c5..216229a60 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -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 }) From fed2c1bf2598dafd88904fd1d2b8a397b893eec4 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Tue, 14 Oct 2025 16:19:31 +0200 Subject: [PATCH 2/8] wip: show federated shares in navigation Signed-off-by: grnd-alt --- lib/Controller/BoardController.php | 9 +++- lib/Controller/PageController.php | 7 +++- lib/Db/ExternalBoard.php | 32 ++++++++++++++ lib/Db/ExternalBoardMapper.php | 25 +++++++++++ lib/Federation/DeckFederationProvider.php | 12 +++++- .../Version11001Date20251014122010.php | 42 +++++++++++++++++++ lib/Service/BoardService.php | 6 +-- lib/Service/ExternalBoardService.php | 18 ++++++++ src/components/navigation/AppNavigation.vue | 15 +++++++ .../navigation/AppNavigationBoardCategory.vue | 3 ++ src/store/main.js | 7 +++- 11 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 lib/Db/ExternalBoard.php create mode 100644 lib/Db/ExternalBoardMapper.php create mode 100644 lib/Migration/Version11001Date20251014122010.php create mode 100644 lib/Service/ExternalBoardService.php diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index 864407438..eb0b98125 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, @@ -34,7 +36,12 @@ public function __construct( #[NoAdminRequired] public function index() { - return $this->boardService->findAll(); + $internalBoards = $this->boardService->findAll(); + $externalBoards = $this->externalBoardService->findAll(); + return [ + 'internal' => $internalBoards, + 'external' => $externalBoards, + ]; } #[NoAdminRequired] diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 0f846bdf6..3f420feed 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,10 @@ 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', [ + "internal" => $this->boardService->findAll(), + "external" => $this->externalBoardService->findAll(), + ]); $this->eventDispatcher->dispatchTyped(new LoadSidebar()); $this->eventDispatcher->dispatchTyped(new CollaborationResourcesEvent()); diff --git a/lib/Db/ExternalBoard.php b/lib/Db/ExternalBoard.php new file mode 100644 index 000000000..9d6303582 --- /dev/null +++ b/lib/Db/ExternalBoard.php @@ -0,0 +1,32 @@ +AddType('id', 'integer'); + $this->addType('title', 'string'); + $this->addType('externalId', 'string'); + $this->addType('owner', 'string'); + $this->addResolvable('participant'); + } +} diff --git a/lib/Db/ExternalBoardMapper.php b/lib/Db/ExternalBoardMapper.php new file mode 100644 index 000000000..d7df1dbdb --- /dev/null +++ b/lib/Db/ExternalBoardMapper.php @@ -0,0 +1,25 @@ + */ +class ExternalBoardMapper extends QBMapper{ + public function __construct( + IDBConnection $db, + ) { + parent::__construct($db, 'deck_boards_external', ExternalBoard::class); + } + + public function findAllForUser(string $userId) { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('deck_boards_external') + ->where($qb->expr()->eq('participant', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->orderBy('id'); + return $this->findEntities($qb); + } +} diff --git a/lib/Federation/DeckFederationProvider.php b/lib/Federation/DeckFederationProvider.php index bb3c6ab1a..d8042fbb3 100644 --- a/lib/Federation/DeckFederationProvider.php +++ b/lib/Federation/DeckFederationProvider.php @@ -1,7 +1,8 @@ setUser($share->getShareWith()); $notification->setDateTime(new \DateTime()); $notification->setObject('remote-board-shared', (string) rand(0,9999999)); - $notification->setSubject('remote-board-shared',[$share->getDescription(), $share->getSharedBy()]); + $notification->setSubject('remote-board-shared',[$share->getResourceName(), $share->getSharedBy()]); $this->notificationManager->notify($notification); + $externalBoard = new ExternalBoard(); + $externalBoard->setTitle($share->getResourceName()); + $externalBoard->setExternalId($share->getProviderId()); + $externalBoard->setOwner($share->getSharedBy()); + $externalBoard->setParticipant($share->getShareWith()); + $this->externalBoardMapper->insert($externalBoard); return 'PLACE_HOLDER_ID'; } diff --git a/lib/Migration/Version11001Date20251014122010.php b/lib/Migration/Version11001Date20251014122010.php new file mode 100644 index 000000000..62e3c41cb --- /dev/null +++ b/lib/Migration/Version11001Date20251014122010.php @@ -0,0 +1,42 @@ +hasTable($tableName)) { + $table = $schema->createTable($tableName); + $table->addColumn('id', 'integer', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 4, + ]); + $table->addColumn('external_id', 'integer', [ + 'notnull' => true, + 'length' => 4, + ]); + $table->addColumn('title', 'string', [ + 'notnull' => true, + 'length' => 100, + ]); + $table->addColumn('owner', 'string', [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('participant', 'string', [ + 'notnull' => true, + 'length' => 64, + ]); + $table->setPrimaryKey(['id']); + } + return $schema; + } +} diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 0f3dde70f..2cf95d730 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -358,9 +358,9 @@ public function addAcl(int $boardId, int $type, $participant, bool $edit, bool $ $token = $this->random->generate(32); $cloudShare = $this->federationFactory->getCloudFederationShare( $participant, // shareWith - $boardId, // name - $board->getTitle(), // description - DeckFederationProvider::PROVIDER_ID, // providerID + $board->getTitle(), // name + '', // description + $boardId, // providerID $sharedBy->getCloudId(), // owner (this instance) $sharedBy->getDisplayName(), // ownerDisplayName (this instance) $sharedBy->getCloudId(), // sharedBy (this instance) diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php new file mode 100644 index 000000000..2f71faf5b --- /dev/null +++ b/lib/Service/ExternalBoardService.php @@ -0,0 +1,18 @@ +externalBoardMapper->findAllForUser($this->userId); + } +} diff --git a/src/components/navigation/AppNavigation.vue b/src/components/navigation/AppNavigation.vue index d6f028c9e..db50e081c 100644 --- a/src/components/navigation/AppNavigation.vue +++ b/src/components/navigation/AppNavigation.vue @@ -43,6 +43,15 @@ + + + @@ -122,6 +131,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') @@ -169,6 +179,11 @@ export default { 'archivedBoards', 'sharedBoards', ]), + ...mapState({ + externalBoards: state => { + return state.externalBoards + }, + }), isAdmin() { return !!getCurrentUser()?.isAdmin }, 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/store/main.js b/src/store/main.js index 216229a60..b493a949f 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -51,7 +51,8 @@ export default new Vuex.Store({ currentBoard: null, currentCard: null, hasCardSaveError: false, - boards: loadState('deck', 'initialBoards', []), + boards: loadState('deck', 'initialBoards', {internal:[]}).internal, + externalBoards: loadState('deck', 'initialBoards', {external:[]}).external, sharees: [], assignableUsers: [], boardFilter: BOARD_FILTERS.ALL, @@ -427,7 +428,9 @@ export default new Vuex.Store({ }, async loadBoards({ commit }) { const boards = await apiClient.loadBoards() - commit('setBoards', boards) + console.log('hello') + console.log(boards) + commit('setBoards', boards.internal) }, async loadSharees({ commit }, query) { const params = new URLSearchParams() From 9e8cecb74b52902ee2542821e36a8033462ee82e Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Tue, 21 Oct 2025 11:14:04 +0200 Subject: [PATCH 3/8] read-only federated boards Signed-off-by: grnd-alt --- appinfo/routes.php | 5 + lib/Controller/BoardController.php | 7 +- lib/Controller/NewBoardController.php | 61 ++++++++ lib/Controller/PageController.php | 3 +- lib/Db/Acl.php | 2 +- lib/Db/AclMapper.php | 2 +- lib/Db/Board.php | 8 + lib/Db/ExternalBoard.php | 32 ---- lib/Db/ExternalBoardMapper.php | 25 ---- lib/Federation/DeckFederationProvider.php | 28 +++- lib/Federation/DeckFederationProxy.php | 140 ++++++++++++++++++ .../Version11001Date20251016122010.php | 24 +++ .../Version11001Date20251020122010.php | 29 ++++ lib/Service/BoardService.php | 10 +- lib/Service/ExternalBoardService.php | 28 +++- lib/Service/PermissionService.php | 53 +++++-- src/components/navigation/AppNavigation.vue | 14 -- .../navigation/AppNavigationBoard.vue | 8 +- src/services/BoardApi.js | 11 +- src/services/StackApi.js | 10 +- src/store/main.js | 7 +- 21 files changed, 383 insertions(+), 124 deletions(-) create mode 100644 lib/Controller/NewBoardController.php delete mode 100644 lib/Db/ExternalBoard.php delete mode 100644 lib/Db/ExternalBoardMapper.php create mode 100644 lib/Federation/DeckFederationProxy.php create mode 100644 lib/Migration/Version11001Date20251016122010.php create mode 100644 lib/Migration/Version11001Date20251020122010.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 4adb828b4..c7da46ccc 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -132,6 +132,11 @@ ['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' => 'Config#get', 'url' => '/api/v{apiVersion}/config', 'verb' => 'GET'], ['name' => 'Config#setValue', 'url' => '/api/v{apiVersion}/config/{key}', 'verb' => 'POST'], diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index eb0b98125..600fc8d0c 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -36,12 +36,7 @@ public function __construct( #[NoAdminRequired] public function index() { - $internalBoards = $this->boardService->findAll(); - $externalBoards = $this->externalBoardService->findAll(); - return [ - 'internal' => $internalBoards, - 'external' => $externalBoards, - ]; + return $this->boardService->findAll(); } #[NoAdminRequired] diff --git a/lib/Controller/NewBoardController.php b/lib/Controller/NewBoardController.php new file mode 100644 index 000000000..4ff1ab495 --- /dev/null +++ b/lib/Controller/NewBoardController.php @@ -0,0 +1,61 @@ +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] + #[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/PageController.php b/lib/Controller/PageController.php index 3f420feed..cec7a87ac 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -56,8 +56,7 @@ public function index(): TemplateResponse { $this->initialState->provideInitialState('config', $this->configService->getAll()); $this->initialState->provideInitialState('initialBoards', [ - "internal" => $this->boardService->findAll(), - "external" => $this->externalBoardService->findAll(), + $this->boardService->findAll(), ]); $this->eventDispatcher->dispatchTyped(new LoadSidebar()); diff --git a/lib/Db/Acl.php b/lib/Db/Acl.php index 49fd6b442..04195946b 100644 --- a/lib/Db/Acl.php +++ b/lib/Db/Acl.php @@ -20,7 +20,7 @@ * @method bool isOwner() * @method void setOwner(int $owner) * @method void setToken(string $token) - * @method string getToken(string $token) + * @method string getToken() * */ class Acl extends RelationalEntity { diff --git a/lib/Db/AclMapper.php b/lib/Db/AclMapper.php index 52c087dd5..006651370 100644 --- a/lib/Db/AclMapper.php +++ b/lib/Db/AclMapper.php @@ -24,7 +24,7 @@ public function __construct(IDBConnection $db) { */ 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/ExternalBoard.php b/lib/Db/ExternalBoard.php deleted file mode 100644 index 9d6303582..000000000 --- a/lib/Db/ExternalBoard.php +++ /dev/null @@ -1,32 +0,0 @@ -AddType('id', 'integer'); - $this->addType('title', 'string'); - $this->addType('externalId', 'string'); - $this->addType('owner', 'string'); - $this->addResolvable('participant'); - } -} diff --git a/lib/Db/ExternalBoardMapper.php b/lib/Db/ExternalBoardMapper.php deleted file mode 100644 index d7df1dbdb..000000000 --- a/lib/Db/ExternalBoardMapper.php +++ /dev/null @@ -1,25 +0,0 @@ - */ -class ExternalBoardMapper extends QBMapper{ - public function __construct( - IDBConnection $db, - ) { - parent::__construct($db, 'deck_boards_external', ExternalBoard::class); - } - - public function findAllForUser(string $userId) { - $qb = $this->db->getQueryBuilder(); - $qb->select('*') - ->from('deck_boards_external') - ->where($qb->expr()->eq('participant', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) - ->orderBy('id'); - return $this->findEntities($qb); - } -} diff --git a/lib/Federation/DeckFederationProvider.php b/lib/Federation/DeckFederationProvider.php index d8042fbb3..a89a3ee3a 100644 --- a/lib/Federation/DeckFederationProvider.php +++ b/lib/Federation/DeckFederationProvider.php @@ -1,8 +1,11 @@ notificationManager->notify($notification); - $externalBoard = new ExternalBoard(); + $externalBoard = new Board(); $externalBoard->setTitle($share->getResourceName()); $externalBoard->setExternalId($share->getProviderId()); $externalBoard->setOwner($share->getSharedBy()); - $externalBoard->setParticipant($share->getShareWith()); - $this->externalBoardMapper->insert($externalBoard); + $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(false); + $acl->setPermissionShare(false); + $acl->setPermissionManage(false); + $this->aclMapper->insert($acl); + + $this->changeHelper->boardChanged($insertedBoard->getId()); return 'PLACE_HOLDER_ID'; } diff --git a/lib/Federation/DeckFederationProxy.php b/lib/Federation/DeckFederationProxy.php new file mode 100644 index 000000000..74afb9f48 --- /dev/null +++ b/lib/Federation/DeckFederationProxy.php @@ -0,0 +1,140 @@ + !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'), + 'nextcloud' => [ + 'allow_local_address' => $this->config->getSystemValueBool('allow_local_remote_servers'), + ], + 'headers' => [ + 'Accept' => 'application/json', + 'X-Nextcloud-Federation' => 'true', + 'OCS-APIRequest' => 'true', + 'Accept-Language' => $this->l10nFactory->getUserLanguage($this->userSession->getUser()), + ], + 'cookies' => CookieJar::fromArray([ + 'XDEBUG_SESSION' => 'PHPSTORM' + ], 'nextcloud2.local'), + 'timeout' => 5, + ]; + + if ($cloudId !== null && $accessToken !== null) { + $options['auth'] = [urlencode($cloudId), $accessToken]; + } + + 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 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/Migration/Version11001Date20251016122010.php b/lib/Migration/Version11001Date20251016122010.php new file mode 100644 index 000000000..c189ee519 --- /dev/null +++ b/lib/Migration/Version11001Date20251016122010.php @@ -0,0 +1,24 @@ +hasTable($tableName)) { + $table = $schema->getTable('deck_boards_external'); + $table->addColumn('share_token', 'string', [ + 'notnull' => true, + 'length' => 32, + ]); + } + return $schema; + } +} diff --git a/lib/Migration/Version11001Date20251020122010.php b/lib/Migration/Version11001Date20251020122010.php new file mode 100644 index 000000000..11458d0bc --- /dev/null +++ b/lib/Migration/Version11001Date20251020122010.php @@ -0,0 +1,29 @@ +hasTable($tableName) && $schema->hasTable('deck_boards')) { + $schema->dropTable($tableName); + $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/Service/BoardService.php b/lib/Service/BoardService.php index 2cf95d730..f9c433436 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -113,7 +113,7 @@ public function findAll(int $since = -1, bool $fullDetails = false, bool $includ * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ - public function find(int $boardId, bool $fullDetails = true, bool $allowDeleted = false): Board { + public function find(int $boardId, bool $fullDetails = true, bool $allowDeleted = false, $accessToken = null): Board { $this->boardServiceValidator->check(compact('boardId')); if (isset($this->boardsCacheFull[$boardId]) && $fullDetails) { @@ -124,9 +124,9 @@ 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, $accessToken); $board = $this->boardMapper->find($boardId, true, true, $allowDeleted); - [$board] = $this->enrichBoards([$board], $fullDetails); + [$board] = $this->enrichBoards([$board], $fullDetails, $accessToken); return $board; } @@ -593,7 +593,7 @@ public function export(int $id): Board { * @param Board[] $boards * @return Board[] */ - private function enrichBoards(array $boards, bool $fullDetails = true): array { + private function enrichBoards(array $boards, bool $fullDetails = true, $accessToken = null): array { $result = []; foreach ($boards as $board) { // FIXME The enrichment in here could make use of combined queries @@ -604,7 +604,7 @@ private function enrichBoards(array $boards, bool $fullDetails = true): array { } } - $permissions = $this->permissionService->matchPermissions($board); + $permissions = $this->permissionService->matchPermissions($board, $accessToken); $board->setPermissions([ 'PERMISSION_READ' => $permissions[Acl::PERMISSION_READ] ?? false, 'PERMISSION_EDIT' => $permissions[Acl::PERMISSION_EDIT] ?? false, diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index 2f71faf5b..4d41ea8d6 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -1,18 +1,36 @@ externalBoardMapper->findAllForUser($this->userId); + public function getExternalBoardFromRemote(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/board/".$localBoard->getExternalId()."?accessToken=".urlencode($shareToken); + $resp = $this->proxy->get($participantCloudId->getId(), $shareToken, $url); + return new DataResponse($this->proxy->getOcsData($resp)); + } + 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()."?accessToken=".urlencode($shareToken); + $resp = $this->proxy->get($participantCloudId->getId(), $shareToken, $url); + return new DataResponse($this->proxy->getOcsData($resp)); } } diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 0f8831daa..c9880fa11 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -52,8 +52,8 @@ public function __construct( * * @return array */ - public function getPermissions(int $boardId, ?string $userId = null): array { - if ($userId === null) { + public function getPermissions(int $boardId, ?string $userId = null, ?string $accessToken = null): array { + if ($userId === null && $accessToken === null) { $userId = $this->userId; } @@ -71,14 +71,23 @@ public function getPermissions(int $boardId, ?string $userId = null): array { $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), - Acl::PERMISSION_MANAGE => $owner || $this->userCan($acls, Acl::PERMISSION_MANAGE, $userId), - Acl::PERMISSION_SHARE => ($owner || $this->userCan($acls, Acl::PERMISSION_SHARE, $userId)) - && (!$this->shareManager->sharingDisabledForUser($userId)) + $permissions = []; + if ($userId !== null) { + $permissions = [ + Acl::PERMISSION_READ => $owner || $this->userCan($acls, Acl::PERMISSION_READ, $userId), + Acl::PERMISSION_EDIT => $owner || $this->userCan($acls, Acl::PERMISSION_EDIT, $userId), + Acl::PERMISSION_MANAGE => $owner || $this->userCan($acls, Acl::PERMISSION_MANAGE, $userId), + Acl::PERMISSION_SHARE => ($owner || $this->userCan($acls, Acl::PERMISSION_SHARE, $userId)) + && (!$this->shareManager->sharingDisabledForUser($userId)) + ]; + } else if ($accessToken !== null ) { + $permissions = [ + Acl::PERMISSION_READ => $owner || $this->externalUserCan($acls, Acl::PERMISSION_READ, $accessToken), + Acl::PERMISSION_EDIT => $owner || $this->externalUserCan($acls, Acl::PERMISSION_EDIT, $accessToken), + Acl::PERMISSION_MANAGE => $owner || $this->externalUserCan($acls, Acl::PERMISSION_MANAGE, $accessToken), + Acl::PERMISSION_SHARE => ($owner || $this->externalUserCan($acls, Acl::PERMISSION_SHARE, $accessToken)) ]; + } $this->permissionCache->set($cacheKey, $permissions); return $permissions; } @@ -90,9 +99,17 @@ public function getPermissions(int $boardId, ?string $userId = null): array { * @return array|bool * @internal param $boardId */ - public function matchPermissions(Board $board) { + public function matchPermissions(Board $board, $accessToken = null) { $owner = $this->userIsBoardOwner($board->getId()); $acls = $board->getAcl() ?? []; + if ($accessToken !== null) { + return [ + Acl::PERMISSION_READ => $owner || $this->externalUserCan($acls, Acl::PERMISSION_READ, $accessToken), + Acl::PERMISSION_EDIT => $owner || $this->externalUserCan($acls, Acl::PERMISSION_EDIT, $accessToken), + Acl::PERMISSION_MANAGE => $owner || $this->externalUserCan($acls, Acl::PERMISSION_MANAGE, $accessToken), + Acl::PERMISSION_SHARE => ($owner || $this->externalUserCan($acls, Acl::PERMISSION_SHARE, $accessToken)) + ]; + } return [ Acl::PERMISSION_READ => $owner || $this->userCan($acls, Acl::PERMISSION_READ), Acl::PERMISSION_EDIT => $owner || $this->userCan($acls, Acl::PERMISSION_EDIT), @@ -107,7 +124,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, $accessToken = null): bool { $boardId = (int)$id; if ($mapper instanceof IPermissionMapper && !($mapper instanceof BoardMapper)) { $boardId = $mapper->findBoardId($id); @@ -117,7 +134,7 @@ public function checkPermission(?IPermissionMapper $mapper, $id, int $permission throw new NoPermissionException('Permission denied'); } - $permissions = $this->getPermissions($boardId, $userId); + $permissions = $this->getPermissions($boardId, $userId, $accessToken); if ($permissions[$permission] === true) { if (!$allowDeletedCard && $mapper instanceof CardMapper) { @@ -165,6 +182,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 * diff --git a/src/components/navigation/AppNavigation.vue b/src/components/navigation/AppNavigation.vue index db50e081c..c3421fd03 100644 --- a/src/components/navigation/AppNavigation.vue +++ b/src/components/navigation/AppNavigation.vue @@ -43,15 +43,6 @@ - - - @@ -179,11 +170,6 @@ export default { 'archivedBoards', 'sharedBoards', ]), - ...mapState({ - externalBoards: state => { - return state.externalBoards - }, - }), isAdmin() { return !!getCurrentUser()?.isAdmin }, 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/services/BoardApi.js b/src/services/BoardApi.js index 7dd96e91c..0095fc8de 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. * @@ -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) diff --git a/src/services/StackApi.js b/src/services/StackApi.js index 890474345..977a0445b 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) diff --git a/src/store/main.js b/src/store/main.js index b493a949f..e0d5c15a2 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -51,8 +51,7 @@ export default new Vuex.Store({ currentBoard: null, currentCard: null, hasCardSaveError: false, - boards: loadState('deck', 'initialBoards', {internal:[]}).internal, - externalBoards: loadState('deck', 'initialBoards', {external:[]}).external, + boards: loadState('deck', 'initialBoards', {}), sharees: [], assignableUsers: [], boardFilter: BOARD_FILTERS.ALL, @@ -428,9 +427,7 @@ export default new Vuex.Store({ }, async loadBoards({ commit }) { const boards = await apiClient.loadBoards() - console.log('hello') - console.log(boards) - commit('setBoards', boards.internal) + commit('setBoards', boards) }, async loadSharees({ commit }, query) { const params = new URLSearchParams() From 8f4866d06dcab907919617b72ee4a69bc6958e84 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Thu, 30 Oct 2025 10:29:00 +0100 Subject: [PATCH 4/8] feat: federated card creation feat: set accessToken from FederationMiddleware Signed-off-by: grnd-alt --- appinfo/routes.php | 3 +- lib/AppInfo/Application.php | 2 + lib/Controller/NewCardController.php | 56 ++++++++++++++ lib/Federation/DeckFederationProvider.php | 2 +- lib/Federation/DeckFederationProxy.php | 29 +++---- lib/Middleware/FederationMiddleware.php | 23 ++++++ .../Version11001Date20251014122010.php | 42 ---------- .../Version11001Date20251016122010.php | 24 ------ .../Version11001Date20251020122010.php | 4 +- lib/Service/BoardService.php | 10 +-- lib/Service/CardService.php | 2 +- lib/Service/ExternalBoardService.php | 35 ++++++++- lib/Service/PermissionService.php | 76 +++++++++++-------- 13 files changed, 184 insertions(+), 124 deletions(-) create mode 100644 lib/Controller/NewCardController.php create mode 100644 lib/Middleware/FederationMiddleware.php delete mode 100644 lib/Migration/Version11001Date20251014122010.php delete mode 100644 lib/Migration/Version11001Date20251016122010.php diff --git a/appinfo/routes.php b/appinfo/routes.php index c7da46ccc..e4f40a2b3 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -132,11 +132,12 @@ ['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_card#create', 'url' => '/api/v{apiVersion}/cards', 'verb' => 'POST'], + ['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 2894d92a1..370d8f566 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -37,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; @@ -113,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); diff --git a/lib/Controller/NewCardController.php b/lib/Controller/NewCardController.php new file mode 100644 index 000000000..66f28473d --- /dev/null +++ b/lib/Controller/NewCardController.php @@ -0,0 +1,56 @@ +boardService->find($boardId, false); + if ($board->getExternalId()) { + $card = $this->externalBoardService->createCardOnRemote($board, $title, $stackId, $type, $order, $description, $duedate, $users); + return new DataResponse($card); + } + } + $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/Federation/DeckFederationProvider.php b/lib/Federation/DeckFederationProvider.php index a89a3ee3a..e38eee4db 100644 --- a/lib/Federation/DeckFederationProvider.php +++ b/lib/Federation/DeckFederationProvider.php @@ -49,7 +49,7 @@ public function shareReceived(ICloudFederationShare $share): string { $acl->setBoardId($insertedBoard->getId()); $acl->setType(Acl::PERMISSION_TYPE_USER); $acl->setParticipant($share->getShareWith()); - $acl->setPermissionEdit(false); + $acl->setPermissionEdit(true); $acl->setPermissionShare(false); $acl->setPermissionManage(false); $this->aclMapper->insert($acl); diff --git a/lib/Federation/DeckFederationProxy.php b/lib/Federation/DeckFederationProxy.php index 74afb9f48..1d80bc9b5 100644 --- a/lib/Federation/DeckFederationProxy.php +++ b/lib/Federation/DeckFederationProxy.php @@ -28,24 +28,22 @@ protected function generateDefaultRequestOptions( ?string $accessToken, ): array { $options = [ - 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'), - 'nextcloud' => [ - 'allow_local_address' => $this->config->getSystemValueBool('allow_local_remote_servers'), - ], - 'headers' => [ - 'Accept' => 'application/json', - 'X-Nextcloud-Federation' => 'true', - 'OCS-APIRequest' => 'true', - 'Accept-Language' => $this->l10nFactory->getUserLanguage($this->userSession->getUser()), - ], - 'cookies' => CookieJar::fromArray([ - 'XDEBUG_SESSION' => 'PHPSTORM' - ], 'nextcloud2.local'), + 'verify' => !$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) { - $options['auth'] = [urlencode($cloudId), $accessToken]; } return $options; @@ -107,6 +105,9 @@ protected function request( 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 getOCSData(IResponse $response, array $allowedStatusCodes = [Http::STATUS_OK]): array { if (!in_array($response->getStatusCode(), $allowedStatusCodes, true)) { $this->logUnexpectedStatusCode(__METHOD__, $response->getStatusCode()); 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/Version11001Date20251014122010.php b/lib/Migration/Version11001Date20251014122010.php deleted file mode 100644 index 62e3c41cb..000000000 --- a/lib/Migration/Version11001Date20251014122010.php +++ /dev/null @@ -1,42 +0,0 @@ -hasTable($tableName)) { - $table = $schema->createTable($tableName); - $table->addColumn('id', 'integer', [ - 'autoincrement' => true, - 'notnull' => true, - 'length' => 4, - ]); - $table->addColumn('external_id', 'integer', [ - 'notnull' => true, - 'length' => 4, - ]); - $table->addColumn('title', 'string', [ - 'notnull' => true, - 'length' => 100, - ]); - $table->addColumn('owner', 'string', [ - 'notnull' => true, - 'length' => 64, - ]); - $table->addColumn('participant', 'string', [ - 'notnull' => true, - 'length' => 64, - ]); - $table->setPrimaryKey(['id']); - } - return $schema; - } -} diff --git a/lib/Migration/Version11001Date20251016122010.php b/lib/Migration/Version11001Date20251016122010.php deleted file mode 100644 index c189ee519..000000000 --- a/lib/Migration/Version11001Date20251016122010.php +++ /dev/null @@ -1,24 +0,0 @@ -hasTable($tableName)) { - $table = $schema->getTable('deck_boards_external'); - $table->addColumn('share_token', 'string', [ - 'notnull' => true, - 'length' => 32, - ]); - } - return $schema; - } -} diff --git a/lib/Migration/Version11001Date20251020122010.php b/lib/Migration/Version11001Date20251020122010.php index 11458d0bc..d4cbe9d20 100644 --- a/lib/Migration/Version11001Date20251020122010.php +++ b/lib/Migration/Version11001Date20251020122010.php @@ -11,9 +11,7 @@ class Version11001Date20251020122010 extends SimpleMigrationStep { public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { $schema = $schemaClosure(); - $tableName = 'deck_boards_external'; - if ($schema->hasTable($tableName) && $schema->hasTable('deck_boards')) { - $schema->dropTable($tableName); + if ($schema->hasTable('deck_boards')) { $table = $schema->getTable('deck_boards'); $table->addColumn('share_token', 'string', [ 'notnull' => false, diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index f9c433436..bdbf7eea4 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -113,7 +113,7 @@ public function findAll(int $since = -1, bool $fullDetails = false, bool $includ * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ - public function find(int $boardId, bool $fullDetails = true, bool $allowDeleted = false, $accessToken = null): Board { + public function find(int $boardId, bool $fullDetails = true, bool $allowDeleted = false): Board { $this->boardServiceValidator->check(compact('boardId')); if (isset($this->boardsCacheFull[$boardId]) && $fullDetails) { @@ -124,9 +124,9 @@ public function find(int $boardId, bool $fullDetails = true, bool $allowDeleted return $this->boardsCachePartial[$boardId]; } - $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ, null, false, $accessToken); + $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ, null, false); $board = $this->boardMapper->find($boardId, true, true, $allowDeleted); - [$board] = $this->enrichBoards([$board], $fullDetails, $accessToken); + [$board] = $this->enrichBoards([$board], $fullDetails); return $board; } @@ -593,7 +593,7 @@ public function export(int $id): Board { * @param Board[] $boards * @return Board[] */ - private function enrichBoards(array $boards, bool $fullDetails = true, $accessToken = null): array { + private function enrichBoards(array $boards, bool $fullDetails = true): array { $result = []; foreach ($boards as $board) { // FIXME The enrichment in here could make use of combined queries @@ -604,7 +604,7 @@ private function enrichBoards(array $boards, bool $fullDetails = true, $accessTo } } - $permissions = $this->permissionService->matchPermissions($board, $accessToken); + $permissions = $this->permissionService->matchPermissions($board); $board->setPermissions([ 'PERMISSION_READ' => $permissions[Acl::PERMISSION_READ] ?? false, 'PERMISSION_EDIT' => $permissions[Acl::PERMISSION_EDIT] ?? false, 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 index 4d41ea8d6..ee8757e54 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -4,6 +4,7 @@ use OCP\AppFramework\Http\DataResponse; use OCA\Deck\Db\Board; +use OCA\Deck\Db\Card; use OCA\Deck\Federation\DeckFederationProxy; use OCP\Federation\ICloudIdManager; use OCP\IUserManager; @@ -21,7 +22,7 @@ public function getExternalBoardFromRemote(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/board/".$localBoard->getExternalId()."?accessToken=".urlencode($shareToken); + $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->proxy->getOcsData($resp)); } @@ -29,8 +30,38 @@ 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()."?accessToken=".urlencode($shareToken); + $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->proxy->getOcsData($resp)); } + + 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); + } } diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index c9880fa11..c4af6c5fb 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,47 +50,58 @@ 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 * * @return array */ - public function getPermissions(int $boardId, ?string $userId = null, ?string $accessToken = null): array { - if ($userId === null && $accessToken === null) { + public function getPermissions(int $boardId, ?string $userId = null): array { + if ($userId === null) { $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 = []; - if ($userId !== null) { - $permissions = [ - Acl::PERMISSION_READ => $owner || $this->userCan($acls, Acl::PERMISSION_READ, $userId), - Acl::PERMISSION_EDIT => $owner || $this->userCan($acls, Acl::PERMISSION_EDIT, $userId), - Acl::PERMISSION_MANAGE => $owner || $this->userCan($acls, Acl::PERMISSION_MANAGE, $userId), - Acl::PERMISSION_SHARE => ($owner || $this->userCan($acls, Acl::PERMISSION_SHARE, $userId)) - && (!$this->shareManager->sharingDisabledForUser($userId)) - ]; - } else if ($accessToken !== null ) { - $permissions = [ - Acl::PERMISSION_READ => $owner || $this->externalUserCan($acls, Acl::PERMISSION_READ, $accessToken), - Acl::PERMISSION_EDIT => $owner || $this->externalUserCan($acls, Acl::PERMISSION_EDIT, $accessToken), - Acl::PERMISSION_MANAGE => $owner || $this->externalUserCan($acls, Acl::PERMISSION_MANAGE, $accessToken), - Acl::PERMISSION_SHARE => ($owner || $this->externalUserCan($acls, Acl::PERMISSION_SHARE, $accessToken)) + $permissions = [ + Acl::PERMISSION_READ => $owner || $this->userCan($acls, Acl::PERMISSION_READ, $userId), + Acl::PERMISSION_EDIT => $owner || $this->userCan($acls, Acl::PERMISSION_EDIT, $userId), + Acl::PERMISSION_MANAGE => $owner || $this->userCan($acls, Acl::PERMISSION_MANAGE, $userId), + Acl::PERMISSION_SHARE => ($owner || $this->userCan($acls, Acl::PERMISSION_SHARE, $userId)) + && (!$this->shareManager->sharingDisabledForUser($userId)) ]; - } $this->permissionCache->set($cacheKey, $permissions); return $permissions; } @@ -99,15 +113,15 @@ public function getPermissions(int $boardId, ?string $userId = null, ?string $ac * @return array|bool * @internal param $boardId */ - public function matchPermissions(Board $board, $accessToken = null) { + public function matchPermissions(Board $board) { $owner = $this->userIsBoardOwner($board->getId()); $acls = $board->getAcl() ?? []; - if ($accessToken !== null) { + if ($this->accessToken !== null) { return [ - Acl::PERMISSION_READ => $owner || $this->externalUserCan($acls, Acl::PERMISSION_READ, $accessToken), - Acl::PERMISSION_EDIT => $owner || $this->externalUserCan($acls, Acl::PERMISSION_EDIT, $accessToken), - Acl::PERMISSION_MANAGE => $owner || $this->externalUserCan($acls, Acl::PERMISSION_MANAGE, $accessToken), - Acl::PERMISSION_SHARE => ($owner || $this->externalUserCan($acls, Acl::PERMISSION_SHARE, $accessToken)) + 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 [ @@ -124,7 +138,7 @@ public function matchPermissions(Board $board, $accessToken = null) { * * @throws NoPermissionException */ - public function checkPermission(?IPermissionMapper $mapper, $id, int $permission, $userId = null, bool $allowDeletedCard = false, $accessToken = null): bool { + public function checkPermission(?IPermissionMapper $mapper, $id, int $permission, $userId = null, bool $allowDeletedCard = false): bool { $boardId = (int)$id; if ($mapper instanceof IPermissionMapper && !($mapper instanceof BoardMapper)) { $boardId = $mapper->findBoardId($id); @@ -134,7 +148,7 @@ public function checkPermission(?IPermissionMapper $mapper, $id, int $permission throw new NoPermissionException('Permission denied'); } - $permissions = $this->getPermissions($boardId, $userId, $accessToken); + $permissions = $this->getPermissions($boardId, $userId); if ($permissions[$permission] === true) { if (!$allowDeletedCard && $mapper instanceof CardMapper) { From cd298c0a71d44d83b9405c3016166af5cda45ec1 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Thu, 30 Oct 2025 11:33:28 +0100 Subject: [PATCH 5/8] feat: map external boardIds back to internal ones for frontend Signed-off-by: grnd-alt --- lib/Service/ExternalBoardService.php | 75 ++++++++++++++++------------ 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index ee8757e54..c6197eabb 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -24,7 +24,7 @@ public function getExternalBoardFromRemote(Board $localBoard):DataResponse { $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->proxy->getOcsData($resp)); + return new DataResponse($this->LocalizeRemoteBoard($this->proxy->getOcsData($resp), $localBoard)); } public function getExternalStacksFromRemote(Board $localBoard):DataResponse { $shareToken = $localBoard->getShareToken(); @@ -32,36 +32,49 @@ public function getExternalStacksFromRemote(Board $localBoard):DataResponse { $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->proxy->getOcsData($resp)); + return new DataResponse($this->LocalizeRemoteStacks($this->proxy->getOcsData($resp), $localBoard)); } - 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 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); + } } From a10fcc4f93bc1fd4f298d136b2fa39cef9dd1fc0 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Thu, 30 Oct 2025 11:35:32 +0100 Subject: [PATCH 6/8] fix: remote card creation Signed-off-by: grnd-alt --- lib/Controller/NewCardController.php | 6 +++++- lib/Listeners/FullTextSearchEventListener.php | 2 +- src/services/CardApi.js | 9 +++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/Controller/NewCardController.php b/lib/Controller/NewCardController.php index 66f28473d..11b554dc9 100644 --- a/lib/Controller/NewCardController.php +++ b/lib/Controller/NewCardController.php @@ -32,7 +32,7 @@ public function __construct( #[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 create(string $title, int $stackId, ?string $type = 'plain',?string $owner = null,?int $order = 999, ?string $description = '', $duedate = null, ?array $labels = [], ?array $users = [],?int $boardId=null) { + public function create(string $title, int $stackId, ?int $boardId=null, ?string $type = 'plain',?string $owner = null,?int $order = 999, ?string $description = '', $duedate = null, ?array $labels = [], ?array $users = []) { if ($boardId) { $board = $this->boardService->find($boardId, false); if ($board->getExternalId()) { @@ -40,6 +40,10 @@ public function create(string $title, int $stackId, ?string $type = 'plain',?str return new DataResponse($card); } } + + if (!$owner) { + $owner = $this->userId; + } $card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate); // foreach ($labels as $label) { 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/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) From f721f5e594eb9df765921bc77ce46054730b1764 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Mon, 3 Nov 2025 11:35:07 +0100 Subject: [PATCH 7/8] feat: create stacks on remote share Signed-off-by: grnd-alt --- appinfo/routes.php | 2 ++ lib/Controller/NewStackController.php | 41 +++++++++++++++++++++++ lib/Db/AclMapper.php | 10 ++++++ lib/Federation/DeckFederationProvider.php | 2 +- lib/Service/ExternalBoardService.php | 19 +++++++++++ lib/Service/PermissionService.php | 6 +++- lib/Service/StackService.php | 2 +- src/services/StackApi.js | 4 +-- 8 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 lib/Controller/NewStackController.php diff --git a/appinfo/routes.php b/appinfo/routes.php index e4f40a2b3..340677209 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -138,6 +138,8 @@ ['name' => 'new_card#create', 'url' => '/api/v{apiVersion}/cards', 'verb' => 'POST'], + ['name' => 'new_stack#create', 'url' => '/api/v{apiVersion}/stacks', 'verb' => 'POST'], + ['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/Controller/NewStackController.php b/lib/Controller/NewStackController.php new file mode 100644 index 000000000..fd262740b --- /dev/null +++ b/lib/Controller/NewStackController.php @@ -0,0 +1,41 @@ +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); + }; + } +} diff --git a/lib/Db/AclMapper.php b/lib/Db/AclMapper.php index 006651370..aa4e2f6dd 100644 --- a/lib/Db/AclMapper.php +++ b/lib/Db/AclMapper.php @@ -18,6 +18,16 @@ 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 diff --git a/lib/Federation/DeckFederationProvider.php b/lib/Federation/DeckFederationProvider.php index e38eee4db..d3b4a53b9 100644 --- a/lib/Federation/DeckFederationProvider.php +++ b/lib/Federation/DeckFederationProvider.php @@ -51,7 +51,7 @@ public function shareReceived(ICloudFederationShare $share): string { $acl->setParticipant($share->getShareWith()); $acl->setPermissionEdit(true); $acl->setPermissionShare(false); - $acl->setPermissionManage(false); + $acl->setPermissionManage(true); $this->aclMapper->insert($acl); $this->changeHelper->boardChanged($insertedBoard->getId()); diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index c6197eabb..8b7073a79 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -77,4 +77,23 @@ public function createCardOnRemote( $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]; + } } diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index c4af6c5fb..87e56be3c 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -138,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); @@ -245,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..2a8837e14 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)); diff --git a/src/services/StackApi.js b/src/services/StackApi.js index 977a0445b..e51d0df80 100644 --- a/src/services/StackApi.js +++ b/src/services/StackApi.js @@ -68,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) From fcae61dddfeb450cb5da0db94cb42f9772ba5925 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Mon, 3 Nov 2025 12:55:27 +0100 Subject: [PATCH 8/8] feat: delete stacks on remote shares Signed-off-by: grnd-alt --- appinfo/routes.php | 1 + lib/Controller/NewStackController.php | 17 +++++++++++++++++ lib/Federation/DeckFederationProxy.php | 3 +++ lib/Service/ExternalBoardService.php | 9 +++++++++ lib/Service/StackService.php | 2 +- src/services/StackApi.js | 6 +++--- src/store/stack.js | 2 +- 7 files changed, 35 insertions(+), 5 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 340677209..405d4e4a4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -139,6 +139,7 @@ ['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/Controller/NewStackController.php b/lib/Controller/NewStackController.php index fd262740b..ec247e70c 100644 --- a/lib/Controller/NewStackController.php +++ b/lib/Controller/NewStackController.php @@ -38,4 +38,21 @@ public function create(string $title, int $boardId, int $order = 0) { 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/Federation/DeckFederationProxy.php b/lib/Federation/DeckFederationProxy.php index 1d80bc9b5..7055cd95a 100644 --- a/lib/Federation/DeckFederationProxy.php +++ b/lib/Federation/DeckFederationProxy.php @@ -108,6 +108,9 @@ public function get(string $cloudId, string $shareToken, string $url, array $par 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()); diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index 8b7073a79..570b433db 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -96,4 +96,13 @@ public function createStackOnRemote( $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/StackService.php b/lib/Service/StackService.php index 2a8837e14..c2aed66e5 100644 --- a/lib/Service/StackService.php +++ b/lib/Service/StackService.php @@ -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/services/StackApi.js b/src/services/StackApi.js index e51d0df80..3e09687bc 100644 --- a/src/services/StackApi.js +++ b/src/services/StackApi.js @@ -97,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/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)