diff --git a/appinfo/routes.php b/appinfo/routes.php index 62aadd02c4..4f94f690dd 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -155,7 +155,5 @@ ['name' => 'Context#destroy', 'url' => '/api/2/contexts/{contextId}', 'verb' => 'DELETE'], ['name' => 'Context#transfer', 'url' => '/api/2/contexts/{contextId}/transfer', 'verb' => 'PUT'], ['name' => 'Context#updateContentOrder', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PUT'], - - ['name' => 'RowOCS#createRow', 'url' => '/api/2/{nodeCollection}/{nodeId}/rows', 'verb' => 'POST', 'requirements' => ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)']], ] ]; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 76c2c2a45c..b6b662f191 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -8,18 +8,22 @@ namespace OCA\Tables\AppInfo; use Exception; +use OC\OCM\OCMSignatoryManager; use OCA\Analytics\Datasource\DatasourceEvent; use OCA\Circles\Events\CircleDestroyedEvent; use OCA\Tables\Capabilities; +use OCA\Tables\Config\ConfigLexicon; use OCA\Tables\Event\RowDeletedEvent; use OCA\Tables\Event\TableDeletedEvent; use OCA\Tables\Event\TableOwnershipTransferredEvent; use OCA\Tables\Event\ViewDeletedEvent; +use OCA\Tables\Federation\FederationProvider; use OCA\Tables\Listener\AddMissingIndicesListener; use OCA\Tables\Listener\AnalyticsDatasourceListener; use OCA\Tables\Listener\BeforeTemplateRenderedListener; use OCA\Tables\Listener\LoadAdditionalListener; use OCA\Tables\Listener\ReceiverCleanupListener; +use OCA\Tables\Listener\ResourceTypeRegisterListener; use OCA\Tables\Listener\TablesReferenceListener; use OCA\Tables\Listener\UserDeletedListener; use OCA\Tables\Listener\WhenRowDeletedAuditLogListener; @@ -42,7 +46,12 @@ use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent; use OCP\DB\Events\AddMissingIndicesEvent; +use OCP\Federation\ICloudFederationProvider; +use OCP\Federation\ICloudFederationProviderManager; use OCP\Group\Events\GroupDeletedEvent; +use OCP\OCM\Events\LocalOCMDiscoveryEvent; +use OCP\Security\Signature\ISignatoryManager; +use OCP\Server; use OCP\User\Events\BeforeUserDeletedEvent; use OCP\User\Events\UserDeletedEvent; use Psr\Container\ContainerInterface; @@ -80,6 +89,7 @@ public function register(IRegistrationContext $context): void { } $context->registerService(AuditLogServiceInterface::class, fn (ContainerInterface $c) => $c->get(DefaultAuditLogService::class)); + $context->registerService(ISignatoryManager::class, fn (ContainerInterface $c) => $c->get(OCMSignatoryManager::class)); $context->registerEventListener(BeforeUserDeletedEvent::class, UserDeletedListener::class); $context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class); @@ -94,6 +104,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserDeletedEvent::class, ReceiverCleanupListener::class); $context->registerEventListener(GroupDeletedEvent::class, ReceiverCleanupListener::class); $context->registerEventListener(CircleDestroyedEvent::class, ReceiverCleanupListener::class); + $context->registerEventListener(LocalOCMDiscoveryEvent::class, ResourceTypeRegisterListener::class); $context->registerSearchProvider(SearchTablesProvider::class); @@ -106,8 +117,19 @@ public function register(IRegistrationContext $context): void { $context->registerMiddleware(ShareControlMiddleware::class); $context->registerUserMigrator(TablesMigrator::class); + + $context->registerConfigLexicon(ConfigLexicon::class); } public function boot(IBootContext $context): void { + $context->injectFn([$this, 'registerCloudFederationProviderManager']); + } + + public function registerCloudFederationProviderManager(ICloudFederationProviderManager $manager): void { + $manager->addCloudFederationProvider( + FederationProvider::PROVIDER_ID, + 'Tables Federation', + static fn (): ICloudFederationProvider => Server::get(FederationProvider::class), + ); } } diff --git a/lib/Config/ConfigLexicon.php b/lib/Config/ConfigLexicon.php new file mode 100644 index 0000000000..5fed49ccd9 --- /dev/null +++ b/lib/Config/ConfigLexicon.php @@ -0,0 +1,43 @@ +tableService = $service; @@ -770,6 +772,11 @@ public function updateShareDisplayMode(int $shareId, int $displayMode, string $t #[CORS] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function indexTableColumns(int $tableId, ?int $viewId): DataResponse { + if ($this->federationService->isNodeFederated($tableId, 'table')) { + $table = $this->tableService->find($tableId, true); + return new DataResponse($this->federationService->getColumns($table)); + } + try { if ($viewId) { $view = $this->viewService->find($viewId, false, $this->userId); @@ -813,6 +820,11 @@ public function indexTableColumns(int $tableId, ?int $viewId): DataResponse { #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')] #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function indexViewColumns(int $viewId): DataResponse { + if ($this->federationService->isNodeFederated($viewId, 'view')) { + $view = $this->viewService->find($viewId, true); + return new DataResponse($this->federationService->getColumns($view)); + } + try { return new DataResponse($this->columnService->formatColumns($this->columnService->findAllByView($viewId))); } catch (PermissionError $e) { diff --git a/lib/Controller/RowController.php b/lib/Controller/RowController.php index 144089f7e4..05bb8b0f87 100644 --- a/lib/Controller/RowController.php +++ b/lib/Controller/RowController.php @@ -9,7 +9,10 @@ use OCA\Tables\AppInfo\Application; use OCA\Tables\Middleware\Attribute\RequirePermission; +use OCA\Tables\Service\FederationService; use OCA\Tables\Service\RowService; +use OCA\Tables\Service\TableService; +use OCA\Tables\Service\ViewService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; @@ -24,6 +27,9 @@ public function __construct( protected LoggerInterface $logger, private RowService $service, private ?string $userId, + private TableService $tableService, + private ViewService $viewService, + private FederationService $federationService, ) { parent::__construct(Application::APP_ID, $request); } @@ -31,6 +37,10 @@ public function __construct( #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] public function index(int $tableId): DataResponse { + if ($this->federationService->isNodeFederated($tableId, 'table')) { + $table = $this->tableService->find($tableId, true); + return new DataResponse($this->federationService->getRows($table)); + } return $this->handleError(function () use ($tableId) { return $this->service->findAllByTable($tableId, $this->userId); }); @@ -39,6 +49,10 @@ public function index(int $tableId): DataResponse { #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')] public function indexView(int $viewId): DataResponse { + if ($this->federationService->isNodeFederated($viewId, 'view')) { + $view = $this->viewService->find($viewId, false, $this->userId); + return new DataResponse($this->federationService->getRows($view)); + } return $this->handleError(function () use ($viewId) { return $this->service->findAllByView($viewId, $this->userId); }); @@ -98,6 +112,9 @@ public function destroyByView(int $id, int $viewId): DataResponse { #[NoAdminRequired] public function presentInView(int $id, int $viewId): DataResponse { + if ($this->federationService->isNodeFederated($viewId, 'view')) { + return new DataResponse(['present' => true]); + } return $this->handleError(function () use ($id, $viewId) { $present = $this->service->isRowInViewPresent($id, $viewId, $this->userId); return ['present' => $present]; diff --git a/lib/Controller/RowOCSController.php b/lib/Controller/RowOCSController.php index 60ef07efaf..4a1acfcf6a 100644 --- a/lib/Controller/RowOCSController.php +++ b/lib/Controller/RowOCSController.php @@ -16,8 +16,12 @@ use OCA\Tables\Middleware\Attribute\RequirePermission; use OCA\Tables\Model\RowDataInput; use OCA\Tables\ResponseDefinitions; +use OCA\Tables\Service\FederationService; use OCA\Tables\Service\RowService; +use OCA\Tables\Service\TableService; +use OCA\Tables\Service\ViewService; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; use OCP\IL10N; @@ -35,6 +39,9 @@ public function __construct( IL10N $n, string $userId, protected RowService $rowService, + private TableService $tableService, + private ViewService $viewService, + private FederationService $federationService, ) { parent::__construct($request, $logger, $n, $userId); } @@ -55,6 +62,7 @@ public function __construct( */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_CREATE, typeParam: 'nodeCollection')] + #[ApiRoute(verb: 'POST', url: '/api/2/{nodeCollection}/{nodeId}/rows', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'])] public function createRow(string $nodeCollection, int $nodeId, mixed $data): DataResponse { if (is_string($data)) { $data = json_decode($data, true); @@ -67,8 +75,16 @@ public function createRow(string $nodeCollection, int $nodeId, mixed $data): Dat $tableId = $viewId = null; if ($iNodeType === Application::NODE_TYPE_TABLE) { $tableId = $nodeId; + if ($this->federationService->isNodeFederated($tableId, 'table')) { + $table = $this->tableService->find($nodeId, true); + return new DataResponse($this->federationService->createRow($table, $data)); + } } elseif ($iNodeType === Application::NODE_TYPE_VIEW) { $viewId = $nodeId; + if ($this->federationService->isNodeFederated($viewId, 'view')) { + $view = $this->viewService->find($nodeId, false, $this->userId); + return new DataResponse($this->federationService->createRow($view, $data)); + } } $newRowData = new RowDataInput(); @@ -88,4 +104,97 @@ public function createRow(string $nodeCollection, int $nodeId, mixed $data): Dat return $this->handleError($e); } } + + /** + * [api v2] Update a row in a table or a view + * + * @param 'tables'|'views' $nodeCollection Indicates whether to update a row on a table or view + * @param int $nodeId The identifier of the targeted table or view + * @param int $rowId The identifier of the row to update + * @param string|array $data An array containing the column identifiers and their values + * @return DataResponse|DataResponse + * + * 200: Row updated + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_UPDATE, typeParam: 'nodeCollection')] + #[ApiRoute(verb: 'PUT', url: '/api/2/{nodeCollection}/{nodeId}/rows/{rowId}', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'])] + public function updateRow(string $nodeCollection, int $nodeId, int $rowId, mixed $data): DataResponse { + if (is_string($data)) { + $data = json_decode($data, true); + } + if (!is_array($data)) { + return $this->handleBadRequestError(new BadRequestError('Cannot update row: data input is invalid.')); + } + $iNodeType = ConversionHelper::stringNodeType2Const($nodeCollection); + $tableId = $viewId = null; + if ($iNodeType === Application::NODE_TYPE_TABLE) { + $tableId = $nodeId; + if ($this->federationService->isNodeFederated($tableId, 'table')) { + $table = $this->tableService->find($nodeId, true); + return new DataResponse($this->federationService->updateRow($table, $rowId, $data)); + } + } elseif ($iNodeType === Application::NODE_TYPE_VIEW) { + $viewId = $nodeId; + if ($this->federationService->isNodeFederated($viewId, 'view')) { + $view = $this->viewService->find($nodeId, false, $this->userId); + return new DataResponse($this->federationService->updateRow($view, $rowId, $data)); + } + } + try { + return new DataResponse($this->rowService->updateSet($rowId, $viewId, $data, $this->userId, $tableId)->jsonSerialize()); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (InternalError|\Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Delete a row in a table or a view + * + * @param 'tables'|'views' $nodeCollection Indicates whether to delete a row on a table or view + * @param int $nodeId The identifier of the targeted table or view + * @param int $rowId The identifier of the row to delete + * @return DataResponse|DataResponse + * + * 200: Row deleted + * 403: No permissions + * 404: Not found + * 500: Internal error + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_DELETE, typeParam: 'nodeCollection')] + #[ApiRoute(verb: 'DELETE', url: '/api/2/{nodeCollection}/{nodeId}/rows/{rowId}', requirements: ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)'])] + public function deleteRow(string $nodeCollection, int $nodeId, int $rowId): DataResponse { + $iNodeType = ConversionHelper::stringNodeType2Const($nodeCollection); + $tableId = $viewId = null; + if ($iNodeType === Application::NODE_TYPE_TABLE) { + $tableId = $nodeId; + if ($this->federationService->isNodeFederated($tableId, 'table')) { + $table = $this->tableService->find($nodeId, true); + return new DataResponse($this->federationService->deleteRow($table, $rowId)); + } + } elseif ($iNodeType === Application::NODE_TYPE_VIEW) { + $viewId = $nodeId; + if ($this->federationService->isNodeFederated($viewId, 'view')) { + $view = $this->viewService->find($nodeId, false, $this->userId); + return new DataResponse($this->federationService->deleteRow($view, $rowId)); + } + } + try { + return new DataResponse($this->rowService->delete($rowId, $viewId, $this->userId, $tableId)->jsonSerialize()); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (InternalError|\Exception $e) { + return $this->handleError($e); + } + } } diff --git a/lib/Db/ShareMapper.php b/lib/Db/ShareMapper.php index bb59ce7ceb..088dafe913 100644 --- a/lib/Db/ShareMapper.php +++ b/lib/Db/ShareMapper.php @@ -7,6 +7,7 @@ namespace OCA\Tables\Db; +use OCA\Tables\Constants\ShareReceiverType; use OCA\Tables\Service\ValueObject\ShareToken; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; @@ -261,4 +262,18 @@ public function deleteByReceiver(string $receiver, string $receiverType): int { ->andWhere($qb->expr()->eq('receiver_type', $qb->createNamedParameter($receiverType, IQueryBuilder::PARAM_STR))) ->executeStatement(); } + + /** + * @return Share[] + * @throws Exception + */ + public function findRemoteSharesForNode(int $nodeId, string $nodeType): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('receiver_type', $qb->createNamedParameter(ShareReceiverType::REMOTE, IQueryBuilder::PARAM_STR))); + return $this->findEntities($qb); + } } diff --git a/lib/Db/Table.php b/lib/Db/Table.php index 418ce443d2..02a47b2fc4 100644 --- a/lib/Db/Table.php +++ b/lib/Db/Table.php @@ -60,6 +60,10 @@ * @method setLastEditBy(string $lastEditBy) * @method getLastEditAt(): string * @method setLastEditAt(string $lastEditAt) + * @method getExternalId(): ?int + * @method setExternalId(?int $externalId) + * @method getShareToken(): ?string + * @method setShareToken(?string $shareToken) */ class Table extends EntitySuper implements JsonSerializable { protected ?string $title = null; @@ -75,6 +79,9 @@ class Table extends EntitySuper implements JsonSerializable { protected ?string $columnOrder = null; // json protected ?string $sort = null; // json + protected ?int $externalId = null; + protected ?string $shareToken = null; + // virtual properties protected ?bool $isShared = null; protected ?Permissions $onSharePermissions = null; @@ -91,6 +98,7 @@ class Table extends EntitySuper implements JsonSerializable { public function __construct() { $this->addType('id', 'integer'); $this->addType('archived', 'boolean'); + $this->addType('externalId', 'integer'); } /** @@ -121,6 +129,7 @@ public function jsonSerialize(): array { $this->getColumnOrderSettingsArray() ), 'sort' => $this->getSortArray(), + 'isFederated' => $this->isFederated(), ]; } @@ -181,4 +190,8 @@ private function getArray(?string $json): array { } return []; } + + public function isFederated(): bool { + return $this->externalId !== null && $this->shareToken !== null; + } } diff --git a/lib/Db/TableMapper.php b/lib/Db/TableMapper.php index e6785f79a1..277f3a8296 100644 --- a/lib/Db/TableMapper.php +++ b/lib/Db/TableMapper.php @@ -105,6 +105,35 @@ public function findAll(?string $userId = null): array { return $entities; } + /** + * @throws Exception + */ + public function findByExternalIdAndToken(int $externalId, string $shareToken): ?Table { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->eq('external_id', $qb->createNamedParameter($externalId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('share_token', $qb->createNamedParameter($shareToken, IQueryBuilder::PARAM_STR))); + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + return null; + } + } + + public function isFederated(int $id): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from($this->table) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNotNull('external_id')) + ->andWhere($qb->expr()->isNotNull('share_token')); + $result = $qb->executeQuery(); + $exists = $result->fetchOne() !== false; + $result->closeCursor(); + return $exists; + } + /** * @throws Exception */ diff --git a/lib/Db/View.php b/lib/Db/View.php index 34783f76ef..9fc3fe9bfc 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -61,6 +61,10 @@ * @method setOwnerDisplayName(string $ownerDisplayName) * @method getOwnership(): ?string * @method setOwnership(string $ownership) + * @method getExternalId(): ?int + * @method setExternalId(?int $externalId) + * @method getShareToken(): ?string + * @method setShareToken(?string $shareToken) */ class View extends EntitySuper implements JsonSerializable { protected ?string $title = null; @@ -75,6 +79,9 @@ class View extends EntitySuper implements JsonSerializable { protected ?string $sort = null; // json protected ?string $filter = null; // json + protected ?int $externalId = null; + protected ?string $shareToken = null; + // virtual properties protected ?bool $isShared = null; protected ?Permissions $onSharePermissions = null; @@ -199,6 +206,7 @@ public function jsonSerialize(): array { 'hasShares' => (bool)$this->hasShares, 'rowsCount' => $this->rowsCount ?: 0, 'ownerDisplayName' => $this->ownerDisplayName, + 'isFederated' => $this->isFederated(), ]; $serialisedJson['filter'] = $this->getFilterArray(); @@ -214,4 +222,8 @@ public function getColumnIds(): array { return array_map(static fn (ViewColumnInformation $column): int => $column->getId(), $columns); } + + public function isFederated(): bool { + return $this->externalId !== null && $this->shareToken !== null; + } } diff --git a/lib/Db/ViewMapper.php b/lib/Db/ViewMapper.php index 531803be5f..4ddd66f6c3 100644 --- a/lib/Db/ViewMapper.php +++ b/lib/Db/ViewMapper.php @@ -42,7 +42,7 @@ public function find(int $id): View { $qb = $this->db->getQueryBuilder(); $qb->select('v.*', 't.ownership') ->from($this->table, 'v') - ->innerJoin('v', 'tables_tables', 't', 't.id = v.table_id') + ->leftJoin('v', 'tables_tables', 't', 't.id = v.table_id') ->where($qb->expr()->eq('v.id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); $entity = $this->findEntity($qb); $this->cache[$cacheKey] = $entity; @@ -75,7 +75,7 @@ public function findMany(array $ids): array { $qb = $this->db->getQueryBuilder(); $qb->select('v.*', 't.ownership') ->from($this->table, 'v') - ->innerJoin('v', 'tables_tables', 't', 't.id = v.table_id') + ->leftJoin('v', 'tables_tables', 't', 't.id = v.table_id') ->where($qb->expr()->in('v.id', $qb->createNamedParameter($missingChunk, IQueryBuilder::PARAM_INT_ARRAY))); foreach ($this->findEntities($qb) as $entity) { @@ -87,6 +87,32 @@ public function findMany(array $ids): array { return $result; } + public function findByExternalIdAndToken(int $externalId, string $shareToken): ?View { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->eq('external_id', $qb->createNamedParameter($externalId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('share_token', $qb->createNamedParameter($shareToken, IQueryBuilder::PARAM_STR))); + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + return null; + } + } + + public function isFederated(int $id): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from($this->table) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNotNull('external_id')) + ->andWhere($qb->expr()->isNotNull('share_token')); + $result = $qb->executeQuery(); + $exists = $result->fetchOne() !== false; + $result->closeCursor(); + return $exists; + } + public function delete(Entity $entity): View { unset($this->cache[(string)$entity->getId()]); return parent::delete($entity); diff --git a/lib/Errors/FederationDisabledError.php b/lib/Errors/FederationDisabledError.php new file mode 100644 index 0000000000..e0707e3b51 --- /dev/null +++ b/lib/Errors/FederationDisabledError.php @@ -0,0 +1,11 @@ +federationService->ensureIncomingFederationEnabled(); + } catch (FederationDisabledError $e) { + throw new ProviderCouldNotAddShareException($e->getMessage()); + } + + $localUser = $share->getShareWith(); + if (str_contains($localUser, '@')) { + $localUser = $this->cloudIdManager->resolveCloudId($localUser)->getUser(); + } + + $metaData = json_decode($share->getDescription(), true) ?? []; + if (!isset($metaData['nodeType'])) { + throw new ProviderCouldNotAddShareException('Missing node type in share description'); + } + + $nodeType = $metaData['nodeType']; + $nodeId = $this->insertFederatedNode($share, $metaData, $nodeType); + $localShare = $this->buildShareForFederationNode($share, $nodeId, $nodeType, $localUser); + + try { + $this->shareMapper->insert($localShare); + } catch (\Exception $e) { + throw new ProviderCouldNotAddShareException('Could not create share for federated share: ' . $e->getMessage()); + } + + return (string)$nodeId; + } + + public function notificationReceived($notificationType, $providerId, $notification): array { + match($notificationType) { + self::NOTIFICATION_UPDATE_PERMISSIONS => $this->handlePermissionUpdate($notification), + self::NOTIFICATION_DELETE_NODE => $this->handleNodeDelete($providerId, $notification), + self::NOTIFICATION_UPDATE_NODE => $this->handleNodeUpdate($providerId, $notification), + default => throw new BadRequestException(['nodeType']), + }; + + return []; + } + + public function getSupportedShareTypes(): array { + return ['user']; + } + + private function getMapperForNodeType(string $nodeType): TableMapper|ViewMapper { + return match($nodeType) { + 'table' => $this->tableMapper, + 'view' => $this->viewMapper, + default => throw new ProviderCouldNotAddShareException('Unsupported node type: ' . $nodeType), + }; + } + + private function insertFederatedNode(ICloudFederationShare $share, array $metaData, string $nodeType): int { + $node = $this->buildNodeForFederationShare($share, $metaData, $nodeType); + + try { + return $this->getMapperForNodeType($nodeType)->insert($node)->getId(); + } catch (\Exception $e) { + throw new ProviderCouldNotAddShareException('Could not add federated ' . $nodeType . ': ' . $e->getMessage()); + } + } + + private function buildNodeForFederationShare(ICloudFederationShare $share, array $metaData, string $nodeType): Table|View { + $node = match($nodeType) { + 'table' => new Table(), + 'view' => new View(), + default => throw new ProviderCouldNotAddShareException('Unsupported node type: ' . $nodeType), + }; + $now = (new \DateTime())->format('Y-m-d H:i:s'); + + $node->setTitle($share->getResourceName()); + $node->setExternalId((int)$share->getProviderId()); + $node->setOwnership($share->getOwner()); + $node->setShareToken($share->getShareSecret()); + $node->setCreatedBy($share->getSharedBy()); + $node->setCreatedAt($now); + $node->setLastEditBy($share->getSharedBy()); + $node->setLastEditAt($now); + $node->setEmoji($metaData['emoji'] ?? null); + + if ($node instanceof View) { + $node->setDescription(''); + } + + return $node; + } + + private function buildShareForFederationNode(ICloudFederationShare $share, int $nodeId, string $nodeType, string $localUser): Share { + $now = (new \DateTime())->format('Y-m-d H:i:s'); + + $localShare = new Share(); + $localShare->setSender($share->getOwner()); + $localShare->setReceiver($localUser); + $localShare->setReceiverType(ShareReceiverType::USER); + $localShare->setNodeId($nodeId); + $localShare->setNodeType($nodeType); + $localShare->setToken($share->getShareSecret()); + $localShare->setPermissionRead(true); + $localShare->setPermissionCreate(false); + $localShare->setPermissionUpdate(false); + $localShare->setPermissionDelete(false); + $localShare->setPermissionManage(false); + $localShare->setCreatedAt($now); + $localShare->setLastEditAt($now); + + return $localShare; + } + + private function findNodeByExternalIdAndToken(string $providerId, array $notification, string $nodeType): Table|View { + $node = $this->getMapperForNodeType($nodeType)->findByExternalIdAndToken((int)$providerId, $notification['sharedSecret']); + if ($node === null) { + throw new ShareNotFound('No matching federated ' . $nodeType . ' found'); + } + + return $node; + } + + private function handlePermissionUpdate(array $notification): void { + try { + $share = $this->shareMapper->findByToken(new ShareToken($notification['sharedSecret'])); + } catch (DoesNotExistException) { + throw new ShareNotFound('No share found for token'); + } + + $share->setPermissionRead($notification['permissionRead']); + $share->setPermissionCreate($notification['permissionCreate']); + $share->setPermissionUpdate($notification['permissionUpdate']); + $share->setPermissionDelete($notification['permissionDelete']); + $this->shareMapper->update($share); + } + + private function handleNodeDelete(string $providerId, array $notification): void { + if (!isset($notification['nodeType'])) { + throw new BadRequestException(['nodeType']); + } + + $nodeType = $notification['nodeType']; + $node = $this->findNodeByExternalIdAndToken($providerId, $notification, $nodeType); + $this->shareMapper->deleteByNode($node->getId(), $nodeType); + $this->getMapperForNodeType($nodeType)->delete($node); + } + + private function handleNodeUpdate(string $providerId, array $notification): void { + if (!isset($notification['nodeType'])) { + throw new BadRequestException(['nodeType']); + } + + $nodeType = $notification['nodeType']; + $node = $this->findNodeByExternalIdAndToken($providerId, $notification, $nodeType); + if (isset($notification['title'])) { + $node->setTitle($notification['title']); + } + if (isset($notification['emoji'])) { + $node->setEmoji($notification['emoji']); + } + $this->getMapperForNodeType($nodeType)->update($node); + } +} diff --git a/lib/Federation/FederationProxy.php b/lib/Federation/FederationProxy.php new file mode 100644 index 0000000000..7402645477 --- /dev/null +++ b/lib/Federation/FederationProxy.php @@ -0,0 +1,174 @@ + !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'), + 'nextcloud' => [ + 'allow_local_address' => $this->config->getSystemValueBool('allow_local_remote_servers'), + ], + 'headers' => [ + 'Accept' => 'application/json', + 'OCS-APIRequest' => 'true', + 'Accept-Language' => $this->l10nFactory->getUserLanguage($this->userSession->getUser()), + 'tables-federation-accesstoken' => $accessToken, + ], + 'timeout' => 5, + ]; + } + + 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, + #[SensitiveParameter] + ?string $accessToken, + string $url, + array $parameters = [], + ): IResponse { + $requestOptions = $this->prepareSignedRequestOptions($verb, $url, $accessToken, $parameters); + + try { + return $this->clientService->newClient()->{$verb}( + $this->prependProtocolIfNotAvailable($url), + $requestOptions + ); + } catch (ClientException $e) { + $status = $e->getResponse()->getStatusCode(); + $body = $e->getResponse()->getBody(); + $content = $body->getContents(); + $body->rewind(); + + if (!is_array(json_decode($content, true))) { + throw new \Exception('Error parsing JSON response', $status); + } + + $this->logger->debug('Client error from remote', ['exception' => $e]); + + /** @psalm-suppress InvalidReturnStatement */ + 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 $shareToken, string $url, array $params = []): IResponse { + return $this->request('get', $shareToken, $url, $params); + } + + public function post(string $shareToken, string $url, array $params = []): IResponse { + return $this->request('post', $shareToken, $url, $params); + } + + public function put(string $shareToken, string $url, array $params = []): IResponse { + return $this->request('put', $shareToken, $url, $params); + } + + public function delete(string $shareToken, string $url): IResponse { + return $this->request('delete', $shareToken, $url); + } + + public function getOCSData(IResponse $response, array $allowedStatusCodes = [Http::STATUS_OK]): array { + if (!in_array($response->getStatusCode(), $allowedStatusCodes, true)) { + $this->logger->debug('Unexpected status code ' . $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', ['exception' => $e]); + throw new \Exception('Error parsing JSON response', $e->getCode(), $e); + } + + return $responseData['ocs']['data'] ?? []; + } + + public function sendNotification(string $type, string $providerId, Share $share, array $extra = []): void { + try { + $cloudId = $this->cloudIdManager->resolveCloudId($share->getReceiver()); + $notification = $this->federationFactory->getCloudFederationNotification(); + $notification->setMessage($type, FederationProvider::PROVIDER_ID, $providerId, + array_merge(['sharedSecret' => $share->getToken()], $extra) + ); + $this->federationProviderManager->sendCloudNotification($cloudId->getRemote(), $notification); + } catch (\Exception $e) { + $this->logger->warning('Could not send federated notification', ['exception' => $e]); + } + } + + private function prepareSignedRequestOptions(string $verb, string $url, ?string $accessToken, array $parameters = []): array { + $options = $this->generateDefaultRequestOptions($accessToken); + $options['body'] = !empty($parameters) ? json_encode($parameters) : ''; + + $options = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $options, + $verb, + $this->prependProtocolIfNotAvailable($url), + ); + + if (!empty($parameters)) { + $options['json'] = json_decode($options['body'], true); + unset($options['body']); + } + + return $options; + } +} diff --git a/lib/Listener/ResourceTypeRegisterListener.php b/lib/Listener/ResourceTypeRegisterListener.php new file mode 100644 index 0000000000..664443fbb5 --- /dev/null +++ b/lib/Listener/ResourceTypeRegisterListener.php @@ -0,0 +1,30 @@ + + */ +class ResourceTypeRegisterListener implements IEventListener { + public function handle(Event $event): void { + if (!$event instanceof LocalOCMDiscoveryEvent) { + return; + } + $event->registerResourceType( + 'tables', + ['user'], + [ + 'tables-v2' => '/ocs/v2.php/apps/tables/api/2/', + ] + ); + } +} diff --git a/lib/Middleware/ShareControlMiddleware.php b/lib/Middleware/ShareControlMiddleware.php index 84a3496405..4460c251eb 100644 --- a/lib/Middleware/ShareControlMiddleware.php +++ b/lib/Middleware/ShareControlMiddleware.php @@ -9,10 +9,12 @@ namespace OCA\Tables\Middleware; use InvalidArgumentException; +use OCA\Tables\Constants\ShareReceiverType; use OCA\Tables\Db\Share; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; use OCA\Tables\Middleware\Attribute\AssertShareAccessIsAccessible; +use OCA\Tables\Service\ConfigService; use OCA\Tables\Service\ShareService; use OCA\Tables\Service\ValueObject\ShareToken; use OCP\AppFramework\Controller; @@ -20,8 +22,10 @@ use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Middleware; use OCP\AppFramework\PublicShareController; +use OCP\Federation\ICloudIdManager; use OCP\IRequest; use OCP\ISession; +use OCP\OCM\IOCMDiscoveryService; use ReflectionMethod; class ShareControlMiddleware extends Middleware { @@ -31,6 +35,9 @@ public function __construct( private readonly IRequest $request, private readonly ShareService $shareService, private readonly ISession $session, + private readonly IOCMDiscoveryService $ocmDiscoveryService, + private readonly ICloudIdManager $cloudIdManager, + private readonly ConfigService $configService, ) { } @@ -72,10 +79,15 @@ private function assertIsAccessible(string $tokenInput): void { /** * @throws NotFoundError * @throws InvalidArgumentException + * @throws PermissionError */ public function assertShareTokenIsValidAndExisting(string $tokenInput): void { $shareToken = new ShareToken($tokenInput); $this->share = $this->shareService->findByToken($shareToken); + + if ($this->share->getReceiverType() === ShareReceiverType::REMOTE) { + $this->assertFederationShare(); + } } public function afterException($controller, $methodName, \Exception $exception): DataResponse { @@ -90,4 +102,21 @@ public function afterException($controller, $methodName, \Exception $exception): } throw $exception; } + + private function assertFederationShare(): void { + if (!$this->configService->isFederationEnabled()) { + throw new PermissionError('Federation is disabled'); + } + + $signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest(); + if ($signedRequest === null) { + throw new PermissionError('Federation requests must be signed'); + } + + $cloudId = $this->cloudIdManager->resolveCloudId($this->share->getReceiver()); + $expectedOrigin = parse_url($cloudId->getRemote(), PHP_URL_HOST); + if ($signedRequest->getOrigin() !== $expectedOrigin) { + throw new PermissionError('Unauthorized federation request origin'); + } + } } diff --git a/lib/Migration/Version002003Date20260630000000.php b/lib/Migration/Version002003Date20260630000000.php new file mode 100644 index 0000000000..f5c8484601 --- /dev/null +++ b/lib/Migration/Version002003Date20260630000000.php @@ -0,0 +1,43 @@ +getTable('tables_tables'); + if (!$table->hasColumn('external_id')) { + $table->addColumn('external_id', Types::INTEGER, ['notnull' => false]); + } + if (!$table->hasColumn('share_token')) { + $table->addColumn('share_token', Types::STRING, ['notnull' => false, 'length' => 64]); + } + + $view = $schema->getTable('tables_views'); + if (!$view->hasColumn('external_id')) { + $view->addColumn('external_id', Types::INTEGER, ['notnull' => false]); + } + if (!$view->hasColumn('share_token')) { + $view->addColumn('share_token', Types::STRING, ['notnull' => false, 'length' => 64]); + } + if ($view->getColumn('table_id')->getNotnull()) { + $view->modifyColumn('table_id', ['notnull' => false]); + } + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 0e6c40ef1c..2cc2c73c7a 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -41,6 +41,7 @@ * }, * hasShares: bool, * rowsCount: int, + * isFederated: bool, * } * * @psalm-type TablesTable = array{ @@ -70,6 +71,7 @@ * columnsCount: int, * columnOrder: list, * sort: list, + * isFederated: bool, * } * * @psalm-type TablesIndex = array{ diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php new file mode 100644 index 0000000000..237f4dca50 --- /dev/null +++ b/lib/Service/ConfigService.php @@ -0,0 +1,34 @@ +appConfig->getValueBool('tables', ConfigLexicon::FEDERATION_ENABLED, true); + } + + public function isOutgoingFederationEnabled(): bool { + return $this->isFederationEnabled() + && $this->appConfig->getValueBool('files_sharing', 'outgoing_server2server_share_enabled', true); + } + + public function isIncomingFederationEnabled(): bool { + return $this->isFederationEnabled() + && $this->appConfig->getValueBool('files_sharing', 'incoming_server2server_share_enabled', true); + } +} diff --git a/lib/Service/FederationService.php b/lib/Service/FederationService.php new file mode 100644 index 0000000000..cd26766e82 --- /dev/null +++ b/lib/Service/FederationService.php @@ -0,0 +1,258 @@ +getOwnership() ?? $node->getCreatedBy(); + $remote = $this->cloudIdManager->resolveCloudId($ownership)->getRemote(); + return $remote . '/ocs/v2.php/apps/tables/api/2/public/' . $node->getShareToken(); + } + + public function getColumns(Table|View $node): array { + try { + $response = $this->proxy->get( + $node->getShareToken(), + $this->getRemoteBaseUrl($node) . '/columns', + ); + return $this->proxy->getOCSData($response); + } catch (\Exception $e) { + $this->logger->error('Could not fetch columns from remote node', ['exception' => $e]); + throw $e; + } + } + + public function getRows(Table|View $node, ?int $limit = null, ?int $offset = null): array { + $url = $this->getRemoteBaseUrl($node) . '/rows'; + $params = array_filter(['limit' => $limit, 'offset' => $offset]); + try { + $response = $this->proxy->get( + $node->getShareToken(), + $url, + $params + ); + return $this->proxy->getOCSData($response); + } catch (\Exception $e) { + $this->logger->error('Could not fetch rows from remote node', ['exception' => $e]); + throw $e; + } + } + + public function createRow(Table|View $node, array $data): array { + try { + $response = $this->proxy->post( + $node->getShareToken(), + $this->getRemoteBaseUrl($node) . '/rows', + ['data' => $data], + ); + return $this->proxy->getOCSData($response); + } catch (\Exception $e) { + $this->logger->error('Could not create row on remote node', ['exception' => $e]); + throw $e; + } + } + + public function updateRow(Table|View $node, int $rowId, array $data): array { + try { + $response = $this->proxy->put( + $node->getShareToken(), + $this->getRemoteBaseUrl($node) . '/rows/' . $rowId, + ['data' => $data], + ); + return $this->proxy->getOCSData($response); + } catch (\Exception $e) { + $this->logger->error('Could not update row on remote node', ['exception' => $e]); + throw $e; + } + } + + public function deleteRow(Table|View $node, int $rowId): array { + try { + $response = $this->proxy->delete( + $node->getShareToken(), + $this->getRemoteBaseUrl($node) . '/rows/' . $rowId, + ); + return $this->proxy->getOCSData($response); + } catch (\Exception $e) { + $this->logger->error('Could not delete row on remote node', ['exception' => $e]); + throw $e; + } + } + + /** + * @throws DoesNotExistException + * @throws Exception + * @throws InternalError + * @throws MultipleObjectsReturnedException + * @throws FederationDisabledError + */ + public function sendShare(Share $share): void { + $this->ensureOutgoingFederationEnabled(); + + $cloudId = $this->cloudIdManager->resolveCloudId($share->getReceiver()); + $ownerCloudId = $this->cloudIdManager->getCloudId($share->getSender(), null); + $ownerDisplayName = $this->userHelper->getUserDisplayName($share->getSender()); + + $node = match($share->getNodeType()) { + 'view' => $this->viewMapper->find($share->getNodeId()), + 'table' => $this->tableMapper->find($share->getNodeId()), + default => throw new InternalError('Unsupported node type for federation: ' . $share->getNodeType()), + }; + + $federationShare = $this->federationFactory->getCloudFederationShare( + $cloudId->getId(), + $node->getTitle(), + json_encode([ + 'emoji' => $node->getEmoji(), + 'nodeType' => $share->getNodeType() + ]), + (string)$share->getNodeId(), + $ownerCloudId->getId(), + $ownerDisplayName, + $ownerCloudId->getId(), + $ownerDisplayName, + $share->getToken(), + FederationProvider::SHARE_TYPE_USER, + FederationProvider::PROVIDER_ID, + ); + try { + $this->federationProviderManager->sendCloudShare($federationShare); + } catch (OCMProviderException $e) { + $this->logger->error('Failed to send federated share: ' . $e->getMessage(), ['exception' => $e]); + throw new InternalError('Could not send federated share to remote instance'); + } + } + + public function notifyNodeDelete(Table|View $node, string $nodeType): void { + try { + $shares = $this->shareMapper->findRemoteSharesForNode($node->getId(), $nodeType); + } catch (\Exception $e) { + $this->logger->warning('Could not fetch remote shares for node deletion notification', ['exception' => $e]); + return; + } + foreach ($shares as $share) { + $this->proxy->sendNotification( + FederationProvider::NOTIFICATION_DELETE_NODE, + (string)$node->getId(), + $share, + ['nodeType' => $nodeType], + ); + } + } + + public function notifyPermissionUpdate(Share $share): void { + $this->proxy->sendNotification( + FederationProvider::NOTIFICATION_UPDATE_PERMISSIONS, + (string)$share->getNodeId(), + $share, + [ + 'permissionRead' => $share->getPermissionRead(), + 'permissionCreate' => $share->getPermissionCreate(), + 'permissionUpdate' => $share->getPermissionUpdate(), + 'permissionDelete' => $share->getPermissionDelete(), + ] + ); + } + + public function notifyNodeUpdate(Table|View $node, string $nodeType): void { + try { + $shares = $this->shareMapper->findRemoteSharesForNode($node->getId(), $nodeType); + } catch (\Exception $e) { + $this->logger->warning('Could not fetch remote shares for node update notification', ['exception' => $e]); + return; + } + foreach ($shares as $share) { + $this->proxy->sendNotification( + FederationProvider::NOTIFICATION_UPDATE_NODE, + (string)$node->getId(), + $share, + [ + 'title' => $node->getTitle(), + 'emoji' => $node->getEmoji(), + 'nodeType' => $nodeType, + ] + ); + } + } + + public function notifyShareDelete(Share $share): void { + $this->proxy->sendNotification( + FederationProvider::NOTIFICATION_DELETE_NODE, + (string)$share->getNodeId(), + $share, + ['nodeType' => $share->getNodeType()], + ); + } + + /** + * @throws FederationDisabledError + */ + public function isNodeFederated(int $id, string $nodeType): bool { + if (!$this->configService->isFederationEnabled()) { + throw new FederationDisabledError('Federation is disabled'); + } + + return match($nodeType) { + 'table' => $this->tableMapper->isFederated($id), + 'view' => $this->viewMapper->isFederated($id), + default => false, + }; + } + + /** + * @throws FederationDisabledError + */ + public function ensureOutgoingFederationEnabled(): void { + if (!$this->configService->isOutgoingFederationEnabled()) { + throw new FederationDisabledError('Federation is disabled'); + } + } + + /** + * @throws FederationDisabledError + */ + public function ensureIncomingFederationEnabled(): void { + if (!$this->configService->isIncomingFederationEnabled()) { + throw new FederationDisabledError('Federation is disabled'); + } + } +} diff --git a/lib/Service/PermissionsService.php b/lib/Service/PermissionsService.php index 69c8cea967..5b5c786468 100644 --- a/lib/Service/PermissionsService.php +++ b/lib/Service/PermissionsService.php @@ -485,7 +485,9 @@ public function getSharedPermissionsIfSharedWithMe(int $elementId, string $eleme // We need to take possibly inherited permissions into account try { $view = $this->viewMapper->find($elementId); - $table = $this->tableMapper->find($view->getTableId()); + if (!$view->isFederated()) { + $table = $this->tableMapper->find($view->getTableId()); + } } catch (DoesNotExistException $e) { throw new NotFoundError($e->getMessage(), $e->getCode(), $e); } catch (MultipleObjectsReturnedException|Exception $e) { @@ -733,6 +735,10 @@ private function basisCheck(Table|View|Context $element, string $nodeType, ?stri return true; } + if ($nodeType === 'view' && $element->isFederated()) { + return false; + } + $shareNodeId = $nodeType === 'view' ? $element->getTableId() : $element->getId(); // Views inherit manage permissions from their parent table, while contexts // must resolve against context shares to avoid cross-type ID collisions. diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 4782e7ec27..72df01be9f 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -771,7 +771,7 @@ public function delete(int $id, ?int $viewId, string $userId, ?int $tableId = nu $this->logger->error($e->getMessage(), ['exception' => $e]); throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } - if (!$this->permissionsService->canDeleteRowsByTableId($item->getTableId())) { + if (!$this->permissionsService->canDeleteRowsByTableId($item->getTableId(), $userId)) { $e = new \Exception('Update row is not allowed.'); $this->logger->error($e->getMessage(), ['exception' => $e]); throw new PermissionError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); @@ -790,7 +790,7 @@ public function delete(int $id, ?int $viewId, string $userId, ?int $tableId = nu objectType: ActivityManager::TABLES_OBJECT_ROW, object: $deletedRow, subject: ActivityManager::SUBJECT_ROW_DELETE, - author: $this->userId, + author: $userId, ); return $this->filterRowResult($view ?? null, $deletedRow); diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index f7d08bb5f8..ef1537ddd4 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -68,6 +68,7 @@ public function __construct( private readonly IUserManager $userManager, private readonly IHasher $hasher, private readonly IShareManager $shareManager, + private readonly FederationService $federationService, ) { parent::__construct($logger, $userId, $permissionsService); } @@ -295,6 +296,10 @@ public function create(ShareCreate $dto): Share { ); } + if ($dto->getReceiverType() === ShareReceiverType::REMOTE) { + $dto->setShareToken($this->generateShareToken()); + } + return $this->createNodeShare( $dto->getNodeId(), $dto->getNodeType(), @@ -401,6 +406,15 @@ private function createNodeShare( throw new InternalError($e->getMessage()); } + if ($receiverType === ShareReceiverType::REMOTE) { + try { + $this->federationService->sendShare($newShare); + } catch (Throwable $e) { + $this->logger->error($e->getMessage()); + throw new InternalError($e->getMessage()); + } + } + return $this->addReceiverDisplayName($newShare); } @@ -454,6 +468,10 @@ private function enforceGroupMembersOnlyPolicy(string $sender, string $receiverT return; } + if ($receiverType === ShareReceiverType::REMOTE) { + throw new PermissionError('Federated sharing is not allowed when sharing is restricted to members of your groups.'); + } + $senderGroupIds = $this->userHelper->getGroupIdsForUser($sender) ?? []; $excludedGroups = $this->shareManager->shareWithGroupMembersOnlyExcludeGroupsList(); if (count(array_intersect($senderGroupIds, $excludedGroups)) > 0) { @@ -584,6 +602,11 @@ public function updatePermission(int $id, array $permissions): Share { } $share = $this->applyPermissions($item, $permissions); + + if ($share->getReceiverType() === ShareReceiverType::REMOTE) { + $this->federationService->notifyPermissionUpdate($share); + } + return $this->addReceiverDisplayName($share); } @@ -646,6 +669,12 @@ public function delete(int $id): Share { try { $this->mapper->delete($item); + + // notify federated shares about share deletion + if ($item->getReceiverType() === ShareReceiverType::REMOTE) { + $this->federationService->notifyShareDelete($item); + } + if ($item->getNodeType() === 'context') { $this->contextNavigationMapper->deleteByShareId($item->getId()); } @@ -680,6 +709,8 @@ private function addReceiverDisplayName(Share $share):Share { ); $share->setReceiverDisplayName($share->getReceiver()); } + } elseif ($share->getReceiverType() === ShareReceiverType::REMOTE) { + $share->setReceiverDisplayName($share->getReceiver()); } else { $this->logger->info('can not use receiver type to get display name'); $share->setReceiverDisplayName($share->getReceiver()); @@ -866,5 +897,4 @@ public function importShare(int $nodeId, array $share, string $userId): void { $this->logger->error('Failed to import share: ' . $e->getMessage(), ['exception' => $e, 'share' => $share]); } } - } diff --git a/lib/Service/TableService.php b/lib/Service/TableService.php index c06f89ccfd..b4598a30ae 100644 --- a/lib/Service/TableService.php +++ b/lib/Service/TableService.php @@ -63,6 +63,7 @@ public function __construct( protected IL10N $l, protected Defaults $themingDefaults, private ActivityManager $activityManager, + private FederationService $federationService, ) { parent::__construct($logger, $userId, $permissionsService); } @@ -438,6 +439,9 @@ public function delete(int $id, ?string $userId = null): Table { } } + // notify federated shares about table deletion + $this->federationService->notifyNodeDelete($item, 'table'); + // delete all shares for that table $this->shareService->deleteAllForTable($item); @@ -525,6 +529,10 @@ public function update(int $id, ?string $title, ?string $emoji, ?string $descrip $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } + + // notify federated shares about table update + $this->federationService->notifyNodeUpdate($table, 'table'); + try { $this->enhanceTable($table, $userId); } catch (InternalError|PermissionError $e) { diff --git a/lib/Service/ValueObject/ShareCreate.php b/lib/Service/ValueObject/ShareCreate.php index 77f870d829..b38bc22bfe 100644 --- a/lib/Service/ValueObject/ShareCreate.php +++ b/lib/Service/ValueObject/ShareCreate.php @@ -73,4 +73,8 @@ public function getPassword(): ?string { public function getShareToken(): ?ShareToken { return $this->shareToken; } + + public function setShareToken(ShareToken $token): void { + $this->shareToken = $token; + } } diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index 8c5ad15890..7af0360f3e 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -68,6 +68,7 @@ public function __construct( IEventDispatcher $eventDispatcher, ContextService $contextService, IL10N $l, + private FederationService $federationService, ) { parent::__construct($logger, $userId, $permissionsService); $this->l = $l; @@ -263,6 +264,10 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b $view->setLastEditBy($userId); $view->setLastEditAt($time->format('Y-m-d H:i:s')); $view = $this->mapper->update($view); + + // notify federated shares about view metadata update + $this->federationService->notifyNodeUpdate($view, 'view'); + if (!$skipTableEnhancement) { $this->enhanceView($view, $userId); } @@ -318,6 +323,10 @@ public function delete(int $id, ?string $userId = null): View { if (!$this->permissionsService->canManageView($view, $userId)) { throw new PermissionError('PermissionError: can not delete view with id ' . $id); } + + // notify federated shares about view deletion + $this->federationService->notifyNodeDelete($view, 'view'); + $this->shareService->deleteAllForView($view); // delete node relations if view is in any context @@ -377,6 +386,11 @@ public function deleteByObject(View $view, ?string $userId = null): View { * $userId can be set or '' */ private function enhanceView(View $view, string $userId): void { + if ($view->isFederated()) { + $this->enhanceFederatedView($view, $userId); + return; + } + // add owner display name for UI $view->setOwnerDisplayName($this->userHelper->getUserDisplayName($view->getOwnership())); @@ -422,6 +436,21 @@ static function (array $sortRule) use ($view): array { } } + private function enhanceFederatedView(View $view, string $userId): void { + $view->setOwnership($view->getCreatedBy()); + $view->setOwnerDisplayName($view->getCreatedBy() ?? ''); + $view->setIsShared(true); + $permissions = $this->shareService->getSharedPermissionsIfSharedWithMe($view->getId(), 'view', $userId); + $view->setOnSharePermissions(new Permissions( + read: $permissions->read, + create: $permissions->create, + update: $permissions->update, + delete: $permissions->delete, + manage: false, + manageTable: false, + )); + } + private function setIsSharedState(View $view, string $userId): void { // set if this is a shared table with you (somebody else shared it with you) // (senseless if we have no user in context) @@ -488,6 +517,7 @@ public function deleteAllByTable(Table $table, ?string $userId = null): void { } $views = $this->findAll($table, $userId); foreach ($views as $view) { + $this->federationService->notifyNodeDelete($view, 'view'); $this->deleteByObject($view, $userId); } } diff --git a/psalm.xml b/psalm.xml index c5e79501f8..e0efd46043 100644 --- a/psalm.xml +++ b/psalm.xml @@ -45,6 +45,10 @@ + + + + diff --git a/src/modules/navigation/partials/NavigationTableItem.vue b/src/modules/navigation/partials/NavigationTableItem.vue index 04b4a06ca0..ec05ee83d0 100644 --- a/src/modules/navigation/partials/NavigationTableItem.vue +++ b/src/modules/navigation/partials/NavigationTableItem.vue @@ -17,7 +17,8 @@