Skip to content

Commit 63c7628

Browse files
committed
feat(federation): federated table and view sharing
Assisted-by: Claude:claude-sonnet-4-6 Signed-off-by: Benjamin Frueh <benjamin.frueh@gmail.com>
1 parent 149c3dd commit 63c7628

34 files changed

Lines changed: 1239 additions & 72 deletions

‎appinfo/routes.php‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,6 @@
156156
['name' => 'Context#transfer', 'url' => '/api/2/contexts/{contextId}/transfer', 'verb' => 'PUT'],
157157
['name' => 'Context#updateContentOrder', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PUT'],
158158

159-
['name' => 'RowOCS#createRow', 'url' => '/api/2/{nodeCollection}/{nodeId}/rows', 'verb' => 'POST', 'requirements' => ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)']],
160-
161159
['name' => 'Config#getTableConfig', 'url' => '/api/2/config/table/{id}', 'verb' => 'GET'],
162160
['name' => 'Config#getViewConfig', 'url' => '/api/2/config/view/{id}', 'verb' => 'GET'],
163161
['name' => 'Config#setValue', 'url' => '/api/2/config/{key}', 'verb' => 'POST'],

‎lib/AppInfo/Application.php‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,22 @@
88
namespace OCA\Tables\AppInfo;
99

1010
use Exception;
11+
use OC\OCM\OCMSignatoryManager;
1112
use OCA\Analytics\Datasource\DatasourceEvent;
1213
use OCA\Circles\Events\CircleDestroyedEvent;
1314
use OCA\Tables\Capabilities;
15+
use OCA\Tables\Config\ConfigLexicon;
1416
use OCA\Tables\Event\RowDeletedEvent;
1517
use OCA\Tables\Event\TableDeletedEvent;
1618
use OCA\Tables\Event\TableOwnershipTransferredEvent;
1719
use OCA\Tables\Event\ViewDeletedEvent;
20+
use OCA\Tables\Federation\FederationProvider;
1821
use OCA\Tables\Listener\AddMissingIndicesListener;
1922
use OCA\Tables\Listener\AnalyticsDatasourceListener;
2023
use OCA\Tables\Listener\BeforeTemplateRenderedListener;
2124
use OCA\Tables\Listener\LoadAdditionalListener;
2225
use OCA\Tables\Listener\ReceiverCleanupListener;
26+
use OCA\Tables\Listener\ResourceTypeRegisterListener;
2327
use OCA\Tables\Listener\TablesReferenceListener;
2428
use OCA\Tables\Listener\UserDeletedListener;
2529
use OCA\Tables\Listener\WhenRowDeletedAuditLogListener;
@@ -43,7 +47,12 @@
4347
use OCP\Collaboration\Reference\RenderReferenceEvent;
4448
use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent;
4549
use OCP\DB\Events\AddMissingIndicesEvent;
50+
use OCP\Federation\ICloudFederationProvider;
51+
use OCP\Federation\ICloudFederationProviderManager;
4652
use OCP\Group\Events\GroupDeletedEvent;
53+
use OCP\OCM\Events\LocalOCMDiscoveryEvent;
54+
use OCP\Security\Signature\ISignatoryManager;
55+
use OCP\Server;
4756
use OCP\User\Events\BeforeUserDeletedEvent;
4857
use OCP\User\Events\UserDeletedEvent;
4958
use Psr\Container\ContainerInterface;
@@ -81,6 +90,7 @@ public function register(IRegistrationContext $context): void {
8190
}
8291

8392
$context->registerService(AuditLogServiceInterface::class, fn (ContainerInterface $c) => $c->get(DefaultAuditLogService::class));
93+
$context->registerService(ISignatoryManager::class, fn (ContainerInterface $c) => $c->get(OCMSignatoryManager::class));
8494

8595
$context->registerEventListener(BeforeUserDeletedEvent::class, UserDeletedListener::class);
8696
$context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class);
@@ -95,6 +105,7 @@ public function register(IRegistrationContext $context): void {
95105
$context->registerEventListener(UserDeletedEvent::class, ReceiverCleanupListener::class);
96106
$context->registerEventListener(GroupDeletedEvent::class, ReceiverCleanupListener::class);
97107
$context->registerEventListener(CircleDestroyedEvent::class, ReceiverCleanupListener::class);
108+
$context->registerEventListener(LocalOCMDiscoveryEvent::class, ResourceTypeRegisterListener::class);
98109

99110
$context->registerSearchProvider(SearchTablesProvider::class);
100111

@@ -109,8 +120,19 @@ public function register(IRegistrationContext $context): void {
109120
$context->registerMiddleware(ShareControlMiddleware::class);
110121

111122
$context->registerUserMigrator(TablesMigrator::class);
123+
124+
$context->registerConfigLexicon(ConfigLexicon::class);
112125
}
113126

114127
public function boot(IBootContext $context): void {
128+
$context->injectFn([$this, 'registerCloudFederationProviderManager']);
129+
}
130+
131+
public function registerCloudFederationProviderManager(ICloudFederationProviderManager $manager): void {
132+
$manager->addCloudFederationProvider(
133+
FederationProvider::PROVIDER_ID,
134+
'Tables Federation',
135+
static fn (): ICloudFederationProvider => Server::get(FederationProvider::class),
136+
);
115137
}
116138
}

‎lib/Config/ConfigLexicon.php‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Tables\Config;
11+
12+
use OCP\Config\Lexicon\Entry;
13+
use OCP\Config\Lexicon\ILexicon;
14+
use OCP\Config\Lexicon\Strictness;
15+
use OCP\Config\ValueType;
16+
17+
/**
18+
* Config Lexicon for tables.
19+
*
20+
* Please Add & Manage your Config Keys in that file and keep the Lexicon up to date!
21+
*
22+
* {@see ILexicon}
23+
*/
24+
class ConfigLexicon implements ILexicon {
25+
public const FEDERATION_ENABLED = 'federationEnabled';
26+
27+
#[\Override]
28+
public function getStrictness(): Strictness {
29+
return Strictness::IGNORE;
30+
}
31+
32+
#[\Override]
33+
public function getAppConfigs(): array {
34+
return [
35+
new Entry(self::FEDERATION_ENABLED, ValueType::BOOL, true, 'Enable or disable federated table sharing'),
36+
];
37+
}
38+
39+
#[\Override]
40+
public function getUserConfigs(): array {
41+
return [];
42+
}
43+
}

‎lib/Constants/ShareReceiverType.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ class ShareReceiverType {
1515
public const GROUP = 'group';
1616
public const CIRCLE = 'circle';
1717
public const LINK = 'link';
18+
public const REMOTE = 'remote';
1819
}

‎lib/Controller/Api1Controller.php‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use OCA\Tables\Model\ViewUpdateInput;
2525
use OCA\Tables\ResponseDefinitions;
2626
use OCA\Tables\Service\ColumnService;
27+
use OCA\Tables\Service\FederationService;
2728
use OCA\Tables\Service\ImportService;
2829
use OCA\Tables\Service\RelationService;
2930
use OCA\Tables\Service\RowService;
@@ -88,6 +89,7 @@ public function __construct(
8889
LoggerInterface $logger,
8990
IL10N $l10N,
9091
?string $userId,
92+
private FederationService $federationService,
9193
) {
9294
parent::__construct(Application::APP_ID, $request);
9395
$this->tableService = $service;
@@ -770,6 +772,11 @@ public function updateShareDisplayMode(int $shareId, int $displayMode, string $t
770772
#[CORS]
771773
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
772774
public function indexTableColumns(int $tableId, ?int $viewId): DataResponse {
775+
if ($this->federationService->isNodeFederated($tableId, 'table')) {
776+
$table = $this->tableService->find($tableId, true);
777+
return new DataResponse($this->federationService->getColumns($table));
778+
}
779+
773780
try {
774781
if ($viewId) {
775782
$view = $this->viewService->find($viewId, false, $this->userId);
@@ -813,6 +820,11 @@ public function indexTableColumns(int $tableId, ?int $viewId): DataResponse {
813820
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')]
814821
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
815822
public function indexViewColumns(int $viewId): DataResponse {
823+
if ($this->federationService->isNodeFederated($viewId, 'view')) {
824+
$view = $this->viewService->find($viewId, true);
825+
return new DataResponse($this->federationService->getColumns($view));
826+
}
827+
816828
try {
817829
return new DataResponse($this->columnService->formatColumns($this->columnService->findAllByView($viewId)));
818830
} catch (PermissionError $e) {

‎lib/Controller/RowController.php‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99

1010
use OCA\Tables\AppInfo\Application;
1111
use OCA\Tables\Middleware\Attribute\RequirePermission;
12+
use OCA\Tables\Service\FederationService;
1213
use OCA\Tables\Service\RowService;
14+
use OCA\Tables\Service\TableService;
15+
use OCA\Tables\Service\ViewService;
1316
use OCP\AppFramework\Controller;
1417
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
1518
use OCP\AppFramework\Http\DataResponse;
@@ -24,13 +27,20 @@ public function __construct(
2427
protected LoggerInterface $logger,
2528
private RowService $service,
2629
private ?string $userId,
30+
private TableService $tableService,
31+
private ViewService $viewService,
32+
private FederationService $federationService,
2733
) {
2834
parent::__construct(Application::APP_ID, $request);
2935
}
3036

3137
#[NoAdminRequired]
3238
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
3339
public function index(int $tableId): DataResponse {
40+
if ($this->federationService->isNodeFederated($tableId, 'table')) {
41+
$table = $this->tableService->find($tableId, true);
42+
return new DataResponse($this->federationService->getRows($table));
43+
}
3444
return $this->handleError(function () use ($tableId) {
3545
return $this->service->findAllByTable($tableId, $this->userId);
3646
});
@@ -39,6 +49,10 @@ public function index(int $tableId): DataResponse {
3949
#[NoAdminRequired]
4050
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')]
4151
public function indexView(int $viewId): DataResponse {
52+
if ($this->federationService->isNodeFederated($viewId, 'view')) {
53+
$view = $this->viewService->find($viewId, false, $this->userId);
54+
return new DataResponse($this->federationService->getRows($view));
55+
}
4256
return $this->handleError(function () use ($viewId) {
4357
return $this->service->findAllByView($viewId, $this->userId);
4458
});
@@ -98,6 +112,9 @@ public function destroyByView(int $id, int $viewId): DataResponse {
98112

99113
#[NoAdminRequired]
100114
public function presentInView(int $id, int $viewId): DataResponse {
115+
if ($this->federationService->isNodeFederated($viewId, 'view')) {
116+
return new DataResponse(['present' => true]);
117+
}
101118
return $this->handleError(function () use ($id, $viewId) {
102119
$present = $this->service->isRowInViewPresent($id, $viewId, $this->userId);
103120
return ['present' => $present];

‎lib/Controller/RowOCSController.php‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616
use OCA\Tables\Middleware\Attribute\RequirePermission;
1717
use OCA\Tables\Model\RowDataInput;
1818
use OCA\Tables\ResponseDefinitions;
19+
use OCA\Tables\Service\FederationService;
1920
use OCA\Tables\Service\RowService;
21+
use OCA\Tables\Service\TableService;
22+
use OCA\Tables\Service\ViewService;
2023
use OCP\AppFramework\Http;
24+
use OCP\AppFramework\Http\Attribute\ApiRoute;
2125
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
2226
use OCP\AppFramework\Http\DataResponse;
2327
use OCP\IL10N;
@@ -35,6 +39,9 @@ public function __construct(
3539
IL10N $n,
3640
string $userId,
3741
protected RowService $rowService,
42+
private TableService $tableService,
43+
private ViewService $viewService,
44+
private FederationService $federationService,
3845
) {
3946
parent::__construct($request, $logger, $n, $userId);
4047
}
@@ -55,6 +62,7 @@ public function __construct(
5562
*/
5663
#[NoAdminRequired]
5764
#[RequirePermission(permission: Application::PERMISSION_CREATE, typeParam: 'nodeCollection')]
65+
#[ApiRoute(verb: 'POST', url: '/api/2/{nodeCollection}/{nodeId}/rows', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'])]
5866
public function createRow(string $nodeCollection, int $nodeId, mixed $data): DataResponse {
5967
if (is_string($data)) {
6068
$data = json_decode($data, true);
@@ -67,8 +75,16 @@ public function createRow(string $nodeCollection, int $nodeId, mixed $data): Dat
6775
$tableId = $viewId = null;
6876
if ($iNodeType === Application::NODE_TYPE_TABLE) {
6977
$tableId = $nodeId;
78+
if ($this->federationService->isNodeFederated($tableId, 'table')) {
79+
$table = $this->tableService->find($nodeId, true);
80+
return new DataResponse($this->federationService->createRow($table, $data));
81+
}
7082
} elseif ($iNodeType === Application::NODE_TYPE_VIEW) {
7183
$viewId = $nodeId;
84+
if ($this->federationService->isNodeFederated($viewId, 'view')) {
85+
$view = $this->viewService->find($nodeId, false, $this->userId);
86+
return new DataResponse($this->federationService->createRow($view, $data));
87+
}
7288
}
7389

7490
$newRowData = new RowDataInput();
@@ -88,4 +104,97 @@ public function createRow(string $nodeCollection, int $nodeId, mixed $data): Dat
88104
return $this->handleError($e);
89105
}
90106
}
107+
108+
/**
109+
* [api v2] Update a row in a table or a view
110+
*
111+
* @param 'tables'|'views' $nodeCollection Indicates whether to update a row on a table or view
112+
* @param int $nodeId The identifier of the targeted table or view
113+
* @param int $rowId The identifier of the row to update
114+
* @param string|array<string, mixed> $data An array containing the column identifiers and their values
115+
* @return DataResponse<Http::STATUS_OK, TablesRow, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
116+
*
117+
* 200: Row updated
118+
* 403: No permissions
119+
* 404: Not found
120+
* 500: Internal error
121+
*/
122+
#[NoAdminRequired]
123+
#[RequirePermission(permission: Application::PERMISSION_UPDATE, typeParam: 'nodeCollection')]
124+
#[ApiRoute(verb: 'PUT', url: '/api/2/{nodeCollection}/{nodeId}/rows/{rowId}', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'])]
125+
public function updateRow(string $nodeCollection, int $nodeId, int $rowId, mixed $data): DataResponse {
126+
if (is_string($data)) {
127+
$data = json_decode($data, true);
128+
}
129+
if (!is_array($data)) {
130+
return $this->handleBadRequestError(new BadRequestError('Cannot update row: data input is invalid.'));
131+
}
132+
$iNodeType = ConversionHelper::stringNodeType2Const($nodeCollection);
133+
$tableId = $viewId = null;
134+
if ($iNodeType === Application::NODE_TYPE_TABLE) {
135+
$tableId = $nodeId;
136+
if ($this->federationService->isNodeFederated($tableId, 'table')) {
137+
$table = $this->tableService->find($nodeId, true);
138+
return new DataResponse($this->federationService->updateRow($table, $rowId, $data));
139+
}
140+
} elseif ($iNodeType === Application::NODE_TYPE_VIEW) {
141+
$viewId = $nodeId;
142+
if ($this->federationService->isNodeFederated($viewId, 'view')) {
143+
$view = $this->viewService->find($nodeId, false, $this->userId);
144+
return new DataResponse($this->federationService->updateRow($view, $rowId, $data));
145+
}
146+
}
147+
try {
148+
return new DataResponse($this->rowService->updateSet($rowId, $viewId, $data, $this->userId, $tableId)->jsonSerialize());
149+
} catch (NotFoundError $e) {
150+
return $this->handleNotFoundError($e);
151+
} catch (PermissionError $e) {
152+
return $this->handlePermissionError($e);
153+
} catch (InternalError|\Exception $e) {
154+
return $this->handleError($e);
155+
}
156+
}
157+
158+
/**
159+
* [api v2] Delete a row in a table or a view
160+
*
161+
* @param 'tables'|'views' $nodeCollection Indicates whether to delete a row on a table or view
162+
* @param int $nodeId The identifier of the targeted table or view
163+
* @param int $rowId The identifier of the row to delete
164+
* @return DataResponse<Http::STATUS_OK, TablesRow, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
165+
*
166+
* 200: Row deleted
167+
* 403: No permissions
168+
* 404: Not found
169+
* 500: Internal error
170+
*/
171+
#[NoAdminRequired]
172+
#[RequirePermission(permission: Application::PERMISSION_DELETE, typeParam: 'nodeCollection')]
173+
#[ApiRoute(verb: 'DELETE', url: '/api/2/{nodeCollection}/{nodeId}/rows/{rowId}', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'])]
174+
public function deleteRow(string $nodeCollection, int $nodeId, int $rowId): DataResponse {
175+
$iNodeType = ConversionHelper::stringNodeType2Const($nodeCollection);
176+
$tableId = $viewId = null;
177+
if ($iNodeType === Application::NODE_TYPE_TABLE) {
178+
$tableId = $nodeId;
179+
if ($this->federationService->isNodeFederated($tableId, 'table')) {
180+
$table = $this->tableService->find($nodeId, true);
181+
return new DataResponse($this->federationService->deleteRow($table, $rowId));
182+
}
183+
} elseif ($iNodeType === Application::NODE_TYPE_VIEW) {
184+
$viewId = $nodeId;
185+
if ($this->federationService->isNodeFederated($viewId, 'view')) {
186+
$view = $this->viewService->find($nodeId, false, $this->userId);
187+
return new DataResponse($this->federationService->deleteRow($view, $rowId));
188+
}
189+
}
190+
try {
191+
return new DataResponse($this->rowService->delete($rowId, $viewId, $this->userId, $tableId)->jsonSerialize());
192+
} catch (NotFoundError $e) {
193+
return $this->handleNotFoundError($e);
194+
} catch (PermissionError $e) {
195+
return $this->handlePermissionError($e);
196+
} catch (InternalError|\Exception $e) {
197+
return $this->handleError($e);
198+
}
199+
}
91200
}

‎lib/Db/ShareMapper.php‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
namespace OCA\Tables\Db;
99

10+
use OCA\Tables\Constants\ShareReceiverType;
1011
use OCA\Tables\Service\ValueObject\ShareToken;
1112
use OCP\AppFramework\Db\DoesNotExistException;
1213
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
@@ -261,4 +262,18 @@ public function deleteByReceiver(string $receiver, string $receiverType): int {
261262
->andWhere($qb->expr()->eq('receiver_type', $qb->createNamedParameter($receiverType, IQueryBuilder::PARAM_STR)))
262263
->executeStatement();
263264
}
265+
266+
/**
267+
* @return Share[]
268+
* @throws Exception
269+
*/
270+
public function findRemoteSharesForNode(int $nodeId, string $nodeType): array {
271+
$qb = $this->db->getQueryBuilder();
272+
$qb->select('*')
273+
->from($this->table)
274+
->where($qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT)))
275+
->andWhere($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_STR)))
276+
->andWhere($qb->expr()->eq('receiver_type', $qb->createNamedParameter(ShareReceiverType::REMOTE, IQueryBuilder::PARAM_STR)));
277+
return $this->findEntities($qb);
278+
}
264279
}

0 commit comments

Comments
 (0)