diff --git a/appinfo/routes.php b/appinfo/routes.php index 62aadd02c4..ff27611c83 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -157,5 +157,7 @@ ['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+)']], + ['name' => 'RowOCS#updateRow', 'url' => '/api/2/{nodeCollection}/{nodeId}/rows/{rowId}', 'verb' => 'PUT', 'requirements' => ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)']], + ['name' => 'RowOCS#deleteRow', 'url' => '/api/2/{nodeCollection}/{nodeId}/rows/{rowId}', 'verb' => 'DELETE', 'requirements' => ['nodeCollection' => '(tables|views)', 'nodeId' => '(\d+)']], ] ]; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 76c2c2a45c..105cb32d72 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -15,11 +15,13 @@ 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 +44,11 @@ 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\Server; use OCP\User\Events\BeforeUserDeletedEvent; use OCP\User\Events\UserDeletedEvent; use Psr\Container\ContainerInterface; @@ -94,6 +100,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); @@ -109,5 +116,14 @@ public function register(IRegistrationContext $context): void { } 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/Constants/ShareReceiverType.php b/lib/Constants/ShareReceiverType.php index 558bf5cb64..e09f492d0e 100644 --- a/lib/Constants/ShareReceiverType.php +++ b/lib/Constants/ShareReceiverType.php @@ -15,4 +15,5 @@ class ShareReceiverType { public const GROUP = 'group'; public const CIRCLE = 'circle'; public const LINK = 'link'; + public const REMOTE = 'remote'; } diff --git a/lib/Controller/Api1Controller.php b/lib/Controller/Api1Controller.php index 1467e46c57..bced950cfd 100644 --- a/lib/Controller/Api1Controller.php +++ b/lib/Controller/Api1Controller.php @@ -24,6 +24,7 @@ use OCA\Tables\Model\ViewUpdateInput; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ColumnService; +use OCA\Tables\Service\FederationService; use OCA\Tables\Service\ImportService; use OCA\Tables\Service\RelationService; use OCA\Tables\Service\RowService; @@ -88,6 +89,7 @@ public function __construct( LoggerInterface $logger, IL10N $l10N, ?string $userId, + private FederationService $federationService, ) { parent::__construct(Application::APP_ID, $request); $this->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 { + $table = $this->tableService->find($tableId, true); + if ($table->isFederated()) { + return new DataResponse($this->federationService->getColumns($table)); + } + try { if ($viewId) { $view = $this->viewService->find($viewId, false, $this->userId); diff --git a/lib/Controller/RowController.php b/lib/Controller/RowController.php index 144089f7e4..f29bac6daa 100644 --- a/lib/Controller/RowController.php +++ b/lib/Controller/RowController.php @@ -9,7 +9,9 @@ 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 OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; @@ -24,6 +26,8 @@ public function __construct( protected LoggerInterface $logger, private RowService $service, private ?string $userId, + private TableService $tableService, + private FederationService $federationService, ) { parent::__construct(Application::APP_ID, $request); } @@ -31,6 +35,10 @@ public function __construct( #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] public function index(int $tableId): DataResponse { + $table = $this->tableService->find($tableId, true); + if ($table->isFederated()) { + return new DataResponse($this->federationService->getRows($table)); + } return $this->handleError(function () use ($tableId) { return $this->service->findAllByTable($tableId, $this->userId); }); diff --git a/lib/Controller/RowOCSController.php b/lib/Controller/RowOCSController.php index 60ef07efaf..05a824b24f 100644 --- a/lib/Controller/RowOCSController.php +++ b/lib/Controller/RowOCSController.php @@ -16,7 +16,9 @@ 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 OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; @@ -35,6 +37,8 @@ public function __construct( IL10N $n, string $userId, protected RowService $rowService, + private TableService $tableService, + private FederationService $federationService, ) { parent::__construct($request, $logger, $n, $userId); } @@ -67,6 +71,10 @@ public function createRow(string $nodeCollection, int $nodeId, mixed $data): Dat $tableId = $viewId = null; if ($iNodeType === Application::NODE_TYPE_TABLE) { $tableId = $nodeId; + $table = $this->tableService->find($nodeId, true); + if ($table->isFederated()) { + return new DataResponse($this->federationService->createRow($table, $data)); + } } elseif ($iNodeType === Application::NODE_TYPE_VIEW) { $viewId = $nodeId; } @@ -88,4 +96,81 @@ 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')] + 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); + $viewId = $iNodeType === Application::NODE_TYPE_VIEW ? $nodeId : null; + if ($iNodeType === Application::NODE_TYPE_TABLE) { + $table = $this->tableService->find($nodeId, true); + if ($table->isFederated()) { + return new DataResponse($this->federationService->updateRow($table, $rowId, $data)); + } + } + try { + return new DataResponse($this->rowService->updateSet($rowId, $viewId, $data, $this->userId, null)->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')] + public function deleteRow(string $nodeCollection, int $nodeId, int $rowId): DataResponse { + $iNodeType = ConversionHelper::stringNodeType2Const($nodeCollection); + if ($iNodeType === Application::NODE_TYPE_TABLE) { + $table = $this->tableService->find($nodeId, true); + if ($table->isFederated()) { + return new DataResponse($this->federationService->deleteRow($table, $rowId)); + } + } + try { + $viewId = $iNodeType === Application::NODE_TYPE_VIEW ? $nodeId : null; + return new DataResponse($this->rowService->delete($rowId, $viewId, $this->userId)->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..aaec31c5c4 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; + } } diff --git a/lib/Db/TableMapper.php b/lib/Db/TableMapper.php index e6785f79a1..0d74947f39 100644 --- a/lib/Db/TableMapper.php +++ b/lib/Db/TableMapper.php @@ -105,6 +105,19 @@ 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))); + $items = $this->findEntities($qb); + return $items[0] ?? null; + } + /** * @throws Exception */ diff --git a/lib/Federation/FederationProvider.php b/lib/Federation/FederationProvider.php new file mode 100644 index 0000000000..75444c2073 --- /dev/null +++ b/lib/Federation/FederationProvider.php @@ -0,0 +1,166 @@ +getShareWith(); + if (str_contains($localUser, '@')) { + $localUser = $this->cloudIdManager->resolveCloudId($localUser)->getUser(); + } + + $table = $this->buildTableFromShare($share); + try { + $insertedTable = $this->tableMapper->insert($table); + } catch (\Exception $e) { + throw new ProviderCouldNotAddShareException('Could not add federated table: ' . $e->getMessage()); + } + + $localShare = $this->buildShareForFederatedTable($share, $insertedTable, $localUser); + try { + $this->shareMapper->insert($localShare); + } catch (\Exception $e) { + throw new ProviderCouldNotAddShareException('Could not create share for federated table: ' . $e->getMessage()); + } + + return (string)$insertedTable->getId(); + } + + public function notificationReceived($notificationType, $providerId, $notification): array { + switch ($notificationType) { + case self::NOTIFICATION_UPDATE_PERMISSIONS: + $this->handlePermissionUpdate($providerId, $notification); + return []; + case self::NOTIFICATION_DELETE_TABLE: + $this->handleTableDelete($providerId, $notification); + return []; + case self::NOTIFICATION_UPDATE_TABLE: + $this->handleTableUpdate($providerId, $notification); + return []; + default: + throw new ActionNotSupportedException('Unknown notification type: ' . $notificationType); + } + } + + public function getSupportedShareTypes(): array { + return ['user']; + } + + private function buildTableFromShare(ICloudFederationShare $share): Table { + $meta = json_decode($share->getDescription(), true) ?? []; + $now = (new \DateTime())->format('Y-m-d H:i:s'); + + $table = new Table(); + $table->setTitle($share->getResourceName()); + $table->setExternalId((int)$share->getProviderId()); + $table->setOwnership($share->getOwner()); + $table->setShareToken($share->getShareSecret()); + $table->setCreatedBy($share->getSharedBy()); + $table->setCreatedAt($now); + $table->setLastEditBy($share->getSharedBy()); + $table->setLastEditAt($now); + $table->setEmoji($meta['emoji'] ?? null); + + return $table; + } + + private function buildShareForFederatedTable(ICloudFederationShare $share, Table $federatedTable, 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($federatedTable->getId()); + $localShare->setNodeType('table'); + $localShare->setPermissionRead(true); + $localShare->setPermissionCreate(true); + $localShare->setPermissionUpdate(true); + $localShare->setPermissionDelete(false); + $localShare->setPermissionManage(false); + $localShare->setCreatedAt($now); + $localShare->setLastEditAt($now); + + return $localShare; + } + + private function handlePermissionUpdate(string $providerId, array $notification): void { + $table = $this->tableMapper->findByExternalIdAndToken((int)$providerId, $notification['sharedSecret']); + if ($table === null) { + throw new ShareNotFound('No matching federated table found'); + } + + $shares = $this->shareMapper->findAllSharesForNode('table', $table->getId()); + $share = $shares[0] ?? null; + if ($share === null) { + throw new ShareNotFound('No share found for federated table'); + } + + $share->setPermissionRead($notification['permissionRead']); + $share->setPermissionCreate($notification['permissionCreate']); + $share->setPermissionUpdate($notification['permissionUpdate']); + $share->setPermissionDelete($notification['permissionDelete']); + $this->shareMapper->update($share); + } + + private function handleTableDelete(string $providerId, array $notification): void { + $table = $this->tableMapper->findByExternalIdAndToken((int)$providerId, $notification['sharedSecret']); + if ($table === null) { + throw new ShareNotFound('No matching federated table found'); + } + $shares = $this->shareMapper->findAllSharesForNode('table', $table->getId()); + foreach ($shares as $share) { + $this->shareMapper->delete($share); + } + $this->tableMapper->delete($table); + } + + private function handleTableUpdate(string $providerId, array $notification): void { + $table = $this->tableMapper->findByExternalIdAndToken((int)$providerId, $notification['sharedSecret']); + if ($table === null) { + throw new ShareNotFound('No matching federated table found'); + } + if (isset($notification['title'])) { + $table->setTitle($notification['title']); + } + if (isset($notification['emoji'])) { + $table->setEmoji($notification['emoji']); + } + $this->tableMapper->update($table); + } +} diff --git a/lib/Federation/FederationProxy.php b/lib/Federation/FederationProxy.php new file mode 100644 index 0000000000..96b4e5cbce --- /dev/null +++ b/lib/Federation/FederationProxy.php @@ -0,0 +1,172 @@ + !$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, $e); + } + + $this->logger->debug('Client error from remote', ['exception' => $e]); + 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..89e3dbdeb4 --- /dev/null +++ b/lib/Listener/ResourceTypeRegisterListener.php @@ -0,0 +1,27 @@ +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..29b84e63f3 100644 --- a/lib/Middleware/ShareControlMiddleware.php +++ b/lib/Middleware/ShareControlMiddleware.php @@ -9,6 +9,7 @@ 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; @@ -20,8 +21,11 @@ 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 Psr\Log\LoggerInterface; 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 LoggerInterface $logger, ) { } @@ -76,6 +83,10 @@ private function assertIsAccessible(string $tokenInput): void { 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 +101,17 @@ public function afterException($controller, $methodName, \Exception $exception): } throw $exception; } + + private function assertFederationShare(): void { + $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..31f3b25397 --- /dev/null +++ b/lib/Migration/Version002003Date20260630000000.php @@ -0,0 +1,39 @@ +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, + ]); + } + + return $schema; + } +} diff --git a/lib/Service/FederationService.php b/lib/Service/FederationService.php new file mode 100644 index 0000000000..b1c49aa886 --- /dev/null +++ b/lib/Service/FederationService.php @@ -0,0 +1,195 @@ +cloudIdManager->resolveCloudId($table->getOwnership())->getRemote(); + return $remote . '/ocs/v2.php/apps/tables/api/2/public/' . $table->getShareToken(); + } + + public function getColumns(Table $table): array { + try { + $response = $this->proxy->get( + $table->getShareToken(), + $this->getRemoteBaseUrl($table) . '/columns', + ); + return $this->proxy->getOCSData($response); + } catch (\Exception $e) { + $this->logger->error('Could not fetch columns from remote table', ['exception' => $e]); + throw $e; + } + } + + public function getRows(Table $table, ?int $limit = null, ?int $offset = null): array { + $url = $this->getRemoteBaseUrl($table) . '/rows'; + $params = array_filter(['limit' => $limit, 'offset' => $offset]); + try { + $response = $this->proxy->get( + $table->getShareToken(), + $url, + $params + ); + return $this->proxy->getOCSData($response); + } catch (\Exception $e) { + $this->logger->error('Could not fetch rows from remote table', ['exception' => $e]); + throw $e; + } + } + + public function createRow(Table $table, array $data): array { + try { + $response = $this->proxy->post( + $table->getShareToken(), + $this->getRemoteBaseUrl($table) . '/rows', + ['data' => $data], + ); + return $this->proxy->getOCSData($response); + } catch (\Exception $e) { + $this->logger->error('Could not create row on remote table', ['exception' => $e]); + throw $e; + } + } + + public function updateRow(Table $table, int $rowId, array $data): array { + try { + $response = $this->proxy->put( + $table->getShareToken(), + $this->getRemoteBaseUrl($table) . '/rows/' . $rowId, + ['data' => $data], + ); + return $this->proxy->getOCSData($response); + } catch (\Exception $e) { + $this->logger->error('Could not update row on remote table', ['exception' => $e]); + throw $e; + } + } + + public function deleteRow(Table $table, int $rowId): array { + try { + $response = $this->proxy->delete( + $table->getShareToken(), + $this->getRemoteBaseUrl($table) . '/rows/' . $rowId, + ); + return $this->proxy->getOCSData($response); + } catch (\Exception $e) { + $this->logger->error('Could not delete row on remote table', ['exception' => $e]); + throw $e; + } + } + + public function sendShare(Share $share): void { + $cloudId = $this->cloudIdManager->resolveCloudId($share->getReceiver()); + $ownerCloudId = $this->cloudIdManager->getCloudId($share->getSender(), null); + $ownerDisplayName = $this->userHelper->getUserDisplayName($share->getSender()); + $table = $this->tableMapper->find($share->getNodeId()); + $federationShare = $this->federationFactory->getCloudFederationShare( + $cloudId->getId(), + $table->getTitle(), + json_encode(['emoji' => $this->tableMapper->find($share->getNodeId())->getEmoji()]), + (string)$share->getNodeId(), + $ownerCloudId->getId(), + $ownerDisplayName, + $ownerCloudId->getId(), + $ownerDisplayName, + $share->getToken(), + '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 notifyTableDeleted(Table $table): void { + try { + $shares = $this->shareMapper->findRemoteSharesForNode($table->getId(), 'table'); + } catch (\Exception $e) { + $this->logger->warning('Could not fetch remote shares for table deletion notification', ['exception' => $e]); + return; + } + foreach ($shares as $share) { + $this->proxy->sendNotification( + FederationProvider::NOTIFICATION_DELETE_TABLE, + (string)$table->getId(), + $share, + ); + } + } + + 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 notifyTableUpdate(Table $table): void { + try { + $shares = $this->shareMapper->findRemoteSharesForNode($table->getId(), 'table'); + } catch (\Exception $e) { + $this->logger->warning('Could not fetch remote shares for table update notification', ['exception' => $e]); + return; + } + foreach ($shares as $share) { + $this->proxy->sendNotification( + FederationProvider::NOTIFICATION_UPDATE_TABLE, + (string)$table->getId(), + $share, + [ + 'title' => $table->getTitle(), + 'emoji' => $table->getEmoji(), + ] + ); + } + } + + public function notifyShareDelete(Share $share): void { + $this->proxy->sendNotification( + FederationProvider::NOTIFICATION_DELETE_TABLE, + (string)$share->getNodeId(), + $share, + ); + } +} diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 2c37ca335a..2285b87781 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -764,7 +764,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()); @@ -781,7 +781,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 72f8a09524..bd2e7326f9 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -69,6 +69,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); } @@ -297,6 +298,10 @@ public function create(ShareCreate $dto): Share { ); } + if ($dto->getReceiverType() === ShareReceiverType::REMOTE) { + $dto->setShareToken($this->generateShareToken()); + } + return $this->createNodeShare( $dto->getNodeId(), $dto->getNodeType(), @@ -403,6 +408,10 @@ private function createNodeShare( throw new InternalError($e->getMessage()); } + if ($receiverType === ShareReceiverType::REMOTE) { + $this->federationService->sendShare($newShare); + } + return $this->addReceiverDisplayName($newShare); } @@ -456,6 +465,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) { @@ -586,6 +599,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); } @@ -648,6 +666,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()); } @@ -682,6 +706,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()); @@ -868,5 +894,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..3ac0566ebd 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->notifyTableDeleted($item); + // 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->notifyTableUpdate($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/src/modules/navigation/partials/NavigationTableItem.vue b/src/modules/navigation/partials/NavigationTableItem.vue index 04b4a06ca0..b70a3276b5 100644 --- a/src/modules/navigation/partials/NavigationTableItem.vue +++ b/src/modules/navigation/partials/NavigationTableItem.vue @@ -17,7 +17,8 @@