Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9e70de4
feat: initiate remote share notifications
grnd-alt Oct 6, 2025
541dffa
wip: show federated shares in navigation
grnd-alt Oct 14, 2025
1f7aba6
read-only federated boards
grnd-alt Oct 21, 2025
cfbd1f6
feat: federated card creation
grnd-alt Oct 30, 2025
d7ca376
feat: map external boardIds back to internal ones for frontend
grnd-alt Oct 30, 2025
912b23b
fix: remote card creation
grnd-alt Oct 30, 2025
82f52c4
feat: create stacks on remote share
grnd-alt Nov 3, 2025
60d4f14
feat: delete stacks on remote shares
grnd-alt Nov 3, 2025
0318e5c
add create to ocs board controller
grnd-alt Nov 12, 2025
63a674a
feat: ocs endpoint for addAcl
grnd-alt Nov 15, 2025
0657551
feat: register deck resource type
grnd-alt Jan 5, 2026
a51f4b1
feat: introduce config value for federation
grnd-alt Feb 2, 2026
a71f404
feat: feature flag for federation
grnd-alt Feb 9, 2026
9e0e1a2
update acl on federated shares
grnd-alt Feb 10, 2026
6339a0a
chore: apply cs:fix
grnd-alt Feb 10, 2026
74299c8
feat: resolve federated owners
grnd-alt Feb 10, 2026
dfa9d30
fix(federation): check permission on local copy of boards
grnd-alt Feb 10, 2026
fc7115a
chore: rename new controllers
grnd-alt Feb 16, 2026
9efeefe
fix: respect admin config values for federation
grnd-alt Feb 16, 2026
2c539be
fix: do not add default permissions to federated shares
grnd-alt Feb 16, 2026
0d63a46
chore: improve ci
grnd-alt Feb 16, 2026
2bf02d9
chore: add reuse compliance headers
grnd-alt Feb 17, 2026
699ae9f
feat: update cards on federated boards
grnd-alt Feb 18, 2026
13df8b7
refactor: use autocomplete for user search
grnd-alt Feb 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@
['name' => 'board_api#preflighted_cors', 'url' => '/api/v{apiVersion}/{path}','verb' => 'OPTIONS', 'requirements' => ['path' => '.+']],
],
'ocs' => [
['name' => 'board_ocs#index', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'GET'],
['name' => 'board_ocs#read', 'url' => '/api/v{apiVersion}/board/{boardId}', 'verb' => 'GET'],
['name' => 'board_ocs#stacks', 'url' => '/api/v{apiVersion}/stacks/{boardId}', 'verb' => 'GET'],
['name' => 'board_ocs#create', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'POST'],
['name' => 'board_ocs#addAcl', 'url' => '/api/v{apiVersion}/boards/{boardId}/acl', 'verb' => 'POST'],

['name' => 'card_ocs#create', 'url' => '/api/v{apiVersion}/cards', 'verb' => 'POST'],
['name' => 'card_ocs#update', 'url' => '/api/v{apiVersion}/cards/{cardId}', 'verb' => 'PUT'],

['name' => 'stack_ocs#create', 'url' => '/api/v{apiVersion}/stacks', 'verb' => 'POST'],
['name' => 'stack_ocs#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'],

Expand Down
2 changes: 1 addition & 1 deletion cypress/e2e/boardFeatures.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('Board', function() {

cy.intercept({
method: 'POST',
url: '/index.php/apps/deck/boards',
url: '/ocs/v2.php/apps/deck/api/v1.0/boards',
}).as('createBoardRequest')

// Click "Add board"
Expand Down
6 changes: 3 additions & 3 deletions cypress/e2e/cardFeatures.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('Card', function () {
it('Create card from overview', function () {
cy.visit(`/apps/deck/#/`)
const newCardTitle = 'Test create from overview'
cy.intercept({ method: 'POST', url: '**/apps/deck/cards' }).as('save')
cy.intercept({ method: 'POST', url: '**/ocs/v2.php/apps/deck/api/v1.0/cards' }).as('save')
cy.intercept({ method: 'GET', url: '**/apps/deck/boards/*' }).as('getBoard')

cy.get('.button-vue[aria-label*="Add card"]')
Expand Down Expand Up @@ -194,7 +194,7 @@ describe('Card', function () {

it('Shows the modal with the editor', () => {
cy.get('.card:contains("Hello world")').should('be.visible').click()
cy.intercept({ method: 'PUT', url: '**/apps/deck/cards/*' }).as('save')
cy.intercept({ method: 'PUT', url: '**/ocs/v2.php/apps/deck/api/v1.0/cards/*' }).as('save')
cy.get('.modal__card').should('be.visible')
cy.get('.app-sidebar-header__mainname').contains('Hello world')
cy.get('.modal__card .ProseMirror h1').contains('Hello world').should('be.visible')
Expand All @@ -213,7 +213,7 @@ describe('Card', function () {

it('Smart picker', () => {
const newCardTitle = 'Test smart picker'
cy.intercept({ method: 'POST', url: '**/apps/deck/cards' }).as('save')
cy.intercept({ method: 'POST', url: '**/ocs/v2.php/apps/deck/api/v1.0/cards' }).as('save')
cy.intercept({ method: 'GET', url: '**/apps/deck/boards/*' }).as('getBoard')
cy.get('.card:contains("Hello world")').should('be.visible').click()
cy.get('.modal__card').should('be.visible')
Expand Down
2 changes: 1 addition & 1 deletion cypress/e2e/deckDashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('Deck dashboard', function() {
}).then((board) => {
cy.visit(`/apps/deck/#/board/${board.id}`)

cy.intercept({ method: 'PUT', url: '**/apps/deck/cards/**' }).as('updateCard')
cy.intercept({ method: 'PUT', url: '**/ocs/v2.php/apps/deck/api/v1.0/cards/**' }).as('updateCard')

const newCardTitle = 'Hello world'
cy.get(`.card:contains("${newCardTitle}")`).should('be.visible').click()
Expand Down
2 changes: 1 addition & 1 deletion cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Cypress.Commands.add('getNavigationEntry', (boardTitle) => {
})

Cypress.Commands.add('shareBoardWithUi', (query, userId=query) => {
cy.intercept({ method: 'GET', url: `**/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=${query}*` }).as('fetchRecipients')
cy.intercept({ method: 'GET', url: `**/ocs/v2.php/core/autocomplete/get?search=${query}*` }).as('fetchRecipients')
cy.get('[aria-label="Open details"]').click()
cy.get('.app-sidebar').should('be.visible')

Expand Down
7 changes: 6 additions & 1 deletion lib/Activity/ActivityManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ public function triggerUpdateEvents($objectType, ChangeSet $changeSet, $subject)
];
if ($changes['before'] !== $changes['after']) {
try {
$event = $this->createEvent($objectType, $entity, $subjectComplete, $changes);
$event = $this->createEvent($objectType, $entity, $subjectComplete, $changes, $this->userId);
if ($event !== null) {
$events[] = $event;
}
Expand Down Expand Up @@ -300,6 +300,11 @@ public function triggerUpdateEvents($objectType, ChangeSet $changeSet, $subject)
* @throws \Exception
*/
private function createEvent($objectType, $entity, $subject, $additionalParams = [], $author = null) {
// @TODO implement actual activities for federated users
// this case only happens for federated activities if the author is not provided
if ($author === null && $this->userId === null) {
return;
}
try {
$object = $this->findObjectForEntity($objectType, $entity);
} catch (DoesNotExistException $e) {
Expand Down
21 changes: 21 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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\AclCreatedRemovedListener;
use OCA\Deck\Listeners\BeforeTemplateRenderedListener;
use OCA\Deck\Listeners\CommentEventListener;
Expand All @@ -35,8 +36,10 @@
use OCA\Deck\Listeners\ParticipantCleanupListener;
use OCA\Deck\Listeners\ResourceAdditionalScriptsListener;
use OCA\Deck\Listeners\ResourceListener;
use OCA\Deck\Listeners\ResourceTypeRegisterListener;
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;
Expand All @@ -60,9 +63,13 @@
use OCP\Comments\CommentsEntityEvent;
use OCP\Comments\CommentsEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Federation\ICloudFederationProvider;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Group\Events\GroupDeletedEvent;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\OCM\Events\ResourceTypeRegisterEvent;
use OCP\Server;
use OCP\Share\IManager;
use OCP\User\Events\UserDeletedEvent;
use OCP\Util;
Expand Down Expand Up @@ -102,6 +109,7 @@ public function boot(IBootContext $context): void {
$context->injectFn(function (Listener $listener, IEventDispatcher $eventDispatcher) {
$listener->register($eventDispatcher);
});
$context->injectFn([$this, 'registerCloudFederationProviderManager']);
}

public function register(IRegistrationContext $context): void {
Expand All @@ -110,6 +118,7 @@ public function register(IRegistrationContext $context): void {
}

$context->registerCapability(Capabilities::class);
$context->registerMiddleWare(FederationMiddleware::class);
$context->registerMiddleWare(ExceptionMiddleware::class);
$context->registerMiddleWare(DefaultBoardMiddleware::class);

Expand All @@ -134,6 +143,7 @@ public function register(IRegistrationContext $context): void {
$context->registerReferenceProvider(CommentReferenceProvider::class);

$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
$context->registerEventListener(ResourceTypeRegisterEvent::class, ResourceTypeRegisterListener::class);

// Event listening to emit UserShareAccessUpdatedEvent for files_sharing
$context->registerEventListener(AclCreatedEvent::class, AclCreatedRemovedListener::class);
Expand Down Expand Up @@ -194,4 +204,15 @@ protected function registerCollaborationResources(IProviderManager $resourceMana
$resourceManager->registerResourceProvider(ResourceProvider::class);
$resourceManager->registerResourceProvider(ResourceProviderCard::class);
}

public function registerCloudFederationProviderManager(
IConfig $config,
ICloudFederationProviderManager $manager,
): void {
$manager->addCloudFederationProvider(
DeckFederationProvider::PROVIDER_ID,
'Deck Federation',
static fn (): ICloudFederationProvider => Server::get(DeckFederationProvider::class),
);
}
}
4 changes: 3 additions & 1 deletion lib/Controller/BoardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use OCA\Deck\Db\Board;
use OCA\Deck\NoPermissionException;
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;
Expand All @@ -25,6 +26,7 @@ public function __construct(
$appName,
IRequest $request,
private BoardService $boardService,
private ExternalBoardService $externalBoardService,
private PermissionService $permissionService,
private BoardImportService $boardImportService,
private IL10N $l10n,
Expand Down Expand Up @@ -83,7 +85,7 @@ public function getUserPermissions(int $boardId): array {
* @param $participant
*/
#[NoAdminRequired]
public function addAcl(int $boardId, int $type, $participant, bool $permissionEdit, bool $permissionShare, bool $permissionManage): Acl {
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);
}

Expand Down
86 changes: 86 additions & 0 deletions lib/Controller/BoardOcsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Deck\Controller;

use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\ExternalBoardService;
use OCA\Deck\Service\StackService;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use Psr\Log\LoggerInterface;

class BoardOcsController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private BoardService $boardService,
private ExternalBoardService $externalBoardService,
private LoggerInterface $logger,
private StackService $stackService,
private $userId,
) {
parent::__construct($appName, $request);
}

#[NoAdminRequired]
public function index(): DataResponse {
$internalBoards = $this->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);
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);
// 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));
}
}

#[NoAdminRequired]
#[NoCSRFRequired]
public function addAcl(int $boardId, int $type, string $participant, bool $permissionEdit, bool $permissionShare, bool $permissionManage, ?string $remote = null): DataResponse {
return new DataResponse($this->boardService->addAcl($boardId, $type, $participant, $permissionEdit, $permissionShare, $permissionManage));
}

#[NoAdminRequired]
#[NoCSRFRequired]
public function updateAcl(int $id, bool $permissionEdit, bool $permissionShare, bool $permissionManage): DataResponse {
return new DataResponse($this->boardService->updateAcl($id, $permissionEdit, $permissionShare, $permissionManage));
}
}
112 changes: 112 additions & 0 deletions lib/Controller/CardOcsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Deck\Controller;

use OCA\Deck\Model\OptionalNullableValue;
use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\CardService;
use OCA\Deck\Service\ExternalBoardService;
use OCA\Deck\Service\StackService;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;

class CardOcsController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private CardService $cardService,
private StackService $stackService,
private BoardService $boardService,
private ExternalBoardService $externalBoardService,
private ?string $userId,
) {
parent::__construct($appName, $request);
}

#[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 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()) {
$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);
}

#[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 update(int $id, string $title, int $stackId, string $type, int $order, string $description, $duedate, $deletedAt, int $boardId, array|string|null $owner = null, $archived = null): DataResponse {
$done = array_key_exists('done', $this->request->getParams())
? new OptionalNullableValue($this->request->getParam('done', null))
: null;
if (!$owner) {
$owner = $this->userId;
} else {
if (!is_string($owner)) {
$owner = $owner['uid'];
}
}

$localBoard = $this->boardService->find($boardId, false);
if ($localBoard->getExternalId()) {
return new DataResponse($this->externalBoardService->updateCardOnRemote(
$localBoard,
$id,
$title,
$stackId,
$type,
$owner,
$description,
$order,
$duedate,
$deletedAt,
$archived,
$done
));
}

return new DataResponse($this->cardService->update($id,
$title,
$stackId,
$type,
$owner,
$description,
$order,
$duedate,
$deletedAt,
$archived,
$done
));
}
}
Loading