diff --git a/appinfo/routes.php b/appinfo/routes.php index ba8fb9864..cb478869a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -7,6 +7,7 @@ return [ 'ocs' => [ + ['name' => 'RemoteActivity#receiveActivities', 'url' => '/api/v2/remote', 'verb' => 'POST'], ['name' => 'RemoteActivity#receiveActivity', 'url' => '/api/v2/remote/{token}', 'verb' => 'POST'], ['name' => 'APIv1#get', 'url' => '/activity', 'verb' => 'GET', 'root' => '/cloud'], ['name' => 'APIv2#getDefault', 'url' => '/api/v2/activity', 'verb' => 'GET'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e199539a4..bb3905cbc 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -11,6 +11,7 @@ use OC\DB\ConnectionAdapter; use OC\SystemConfig; use OCA\Activity\Capabilities; +use OCA\Activity\ConfigLexicon; use OCA\Activity\Consumer; use OCA\Activity\Dashboard\ActivityWidget; use OCA\Activity\Data; @@ -141,7 +142,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserDeletedEvent::class, UserDeleted::class); $context->registerEventListener(PostLoginEvent::class, SetUserDefaults::class); $context->registerDashboardWidget(ActivityWidget::class); - + $context->registerConfigLexicon(ConfigLexicon::class); $this->registerFilesActivity($context); } diff --git a/lib/BackgroundJob/RemoteActivity.php b/lib/BackgroundJob/RemoteActivity.php index 6ab022b23..e39de0d99 100644 --- a/lib/BackgroundJob/RemoteActivity.php +++ b/lib/BackgroundJob/RemoteActivity.php @@ -11,27 +11,32 @@ use OCA\Activity\Extension\Files; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\QueuedJob; -use OCP\Federation\ICloudId; use OCP\Federation\ICloudIdManager; use OCP\Http\Client\IClientService; +use Psr\Log\LoggerInterface; class RemoteActivity extends QueuedJob { public function __construct( ITimeFactory $timeFactory, private readonly IClientService $clientService, private readonly ICloudIdManager $cloudIdManager, + private readonly LoggerInterface $logger, ) { parent::__construct($timeFactory); } #[\Override] protected function run($argument) { - call_user_func_array($this->sendActivity(...), $argument); + if (is_array($argument) && array_key_exists('instances', $argument)) { + foreach ($argument['instances'] as $instance => $entries) { + $this->sendActivities($instance, $entries); + } + } else { + call_user_func_array($this->sendActivity(...), $argument); + } } protected function sendActivity($target, $token, $path, $internalType, $time, $actor, $secondPath = '') { - $client = $this->clientService->newClient(); - $cloudId = $this->cloudIdManager->resolveCloudId($target); $type = $this->translateType($internalType, $secondPath); @@ -65,28 +70,106 @@ protected function sendActivity($target, $token, $path, $internalType, $time, $a ]; } + $this->sendRemote($cloudId->getRemote(), $token, $fields); + } + + protected function sendActivities(string $instance, array $entries) { + if (count($entries) === 1) { + $this->sendActivity(...$entries[0]); + return; + } + + $items = []; + foreach ($entries as $entry) { + [$target, $token, $path, $internalType, $time, $actor, $secondPath] = array_merge(array_fill(0, 6, ''), $entry); + $cloudId = $this->cloudIdManager->resolveCloudId($target); + + $type = $this->translateType($internalType, $secondPath); + + $item = [ + 'to' => [ + 'type' => 'Person', + 'name' => $cloudId->getUser(), + ], + 'actor' => [ + 'type' => 'Person', + 'name' => $actor, + ], + 'type' => $type, + 'share' => [ + 'type' => 'Document', + 'token' => $token, + ], + 'updated' => date(\DateTime::W3C, (int)$time), + ]; + + if ($type === 'Move') { + $item['target'] = [ + 'type' => 'Document', + 'name' => $path, + ]; + $item['origin'] = [ + 'type' => 'Document', + 'name' => $secondPath, + ]; + } else { + $item['object'] = [ + 'type' => 'Document', + 'name' => $path, + ]; + } + + $items[] = $item; + } + + $this->sendRemoteCollection($instance, $items); + } + + public function sendRemoteCollection(string $instance, array $items): void { + if (empty($items)) { + return; + } + + $collection = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Collection', + 'totalItems' => count($items), + 'items' => $items + ]; + + $this->sendRemote($instance, null, $collection); + } + + public function sendRemote(string $instance, ?string $token, array $data): void { + $client = $this->clientService->newClient(); + try { $client->post( - $this->getServerURL($cloudId, $token), [ - 'body' => $fields, + $this->getServerURL($instance, $token), [ + 'body' => $data, 'timeout' => 10, 'connect_timeout' => 10, ] ); - } catch (ClientException) { + } catch (ClientException $e) { + $this->logger->warning('Failed to send remote activity', ['exception' => $e]); } } /** - * @param string $token - * @return string + * returns the url to reach remote activity apps ocs endpoint + * + * will use the receiveActivities() endpoint is token is NULL */ - protected function getServerURL(ICloudId $cloudId, $token) { - $remote = $cloudId->getRemote(); + protected function getServerURL(string $remote, ?string $token): string { if (!str_starts_with($remote, 'http')) { $remote = 'https://' . $remote; } + if ($token === null) { + return rtrim($remote, '/') . '/ocs/v2.php/apps/activity/api/v2/remote'; + } + return rtrim($remote, '/') . '/ocs/v2.php/apps/activity/api/v2/remote/' . $token; } diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php new file mode 100644 index 000000000..a3b4f65e9 --- /dev/null +++ b/lib/ConfigLexicon.php @@ -0,0 +1,43 @@ +getTimestamp() - $this->timeFactory->getTime()) > 600) { + try { + $this->manageActivity($token, $type, $to, $actor, $updated, $object, $target, $origin); + } catch (RemoteActivityException $e) { + $this->logger->warning('issue with data from a remote activity', ['exception' => $e]); + $response = new DataResponse($e->getResponse(), $e->getCode()); + $response->throttle(); + return $response; + } + + return new DataResponse(); + } + + /** + * @PublicPage + * @NoCSRFRequired + * + * @param string $token + * @param string[] $to + * @param string[] $actor + * @param string $type + * @param string $updated + * @param string[] $object + * @param string[] $target + * @param string[] $origin + */ + #[BruteForceProtection(action: 'receiveActivity')] + public function receiveActivities(string $type, array $items = [], int $totalItems = 0): DataResponse { + if (!$this->appManager->isEnabledForAnyone('federatedfilesharing')) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + if ($type !== 'Collection' || count($items) <> $totalItems) { return new DataResponse([], Http::STATUS_BAD_REQUEST); } + $this->manageCollection($items); + return new DataResponse(); + } + + protected function manageActivity( + string $token, + string $type, + array $to, + array $actor, + string $updated, + array $object, + array $target, + array $origin = [], + ): void { if (!isset($to['type'], $to['name']) || $to['type'] !== 'Person') { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('issue with to/type entry', Http::STATUS_BAD_REQUEST); } $user = $this->userManager->get($to['name']); if (!$user instanceof IUser) { - $response = new DataResponse([], Http::STATUS_NOT_FOUND); - $response->throttle(); - return $response; + throw new RemoteActivityException('user not found', Http::STATUS_NOT_FOUND); } if (!isset($actor['type'], $actor['name']) || $actor['type'] !== 'Person') { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('issue with actor/type entry', Http::STATUS_BAD_REQUEST); } if ($user->getCloudId() === $actor['name']) { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('issue with actor/name entry', Http::STATUS_BAD_REQUEST); } if (isset($object['name']) && preg_match('/(^|\/)\.\.(\/|$)/', $object['name'])) { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('issue with object/name entry', Http::STATUS_BAD_REQUEST); } try { @@ -94,12 +144,12 @@ public function receiveActivity($token, array $to, array $actor, $type, $updated $actorServer = $resolved->getRemote(); $actorUser = $resolved->getUser(); } catch (\InvalidArgumentException) { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('issue with actor/name cloudId', Http::STATUS_BAD_REQUEST); } $internalType = $this->translateType($type); if ($internalType === '') { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('internal type', Http::STATUS_BAD_REQUEST); } $query = $this->db->getQueryBuilder(); @@ -113,40 +163,43 @@ public function receiveActivity($token, array $to, array $actor, $type, $updated $share = $result->fetch(); $result->closeCursor(); + $date = \DateTime::createFromFormat(DateTimeInterface::W3C, $updated); if (!is_array($share) || str_starts_with($share['mountpoint'], '{{TemporaryMountPointName#')) { - $response = new DataResponse([], Http::STATUS_NOT_FOUND); - $response->throttle(); - return $response; + throw new RemoteActivityException('issue with share mountpoint', Http::STATUS_NOT_FOUND); + } + + if ($date === false || abs($date->getTimestamp() - $this->timeFactory->getTime()) > $this->calculateTTL($share['remote'] ?? '')) { + throw new RemoteActivityException('date/TTL is out of range', Http::STATUS_BAD_REQUEST); } $normalizedActorServer = rtrim(strtolower((string)preg_replace('/^https?:\/\//', '', $actorServer)), '/'); $normalizedShareRemote = rtrim(strtolower(preg_replace('/^https?:\/\//', '', $share['remote'])), '/'); if ($normalizedActorServer !== $normalizedShareRemote) { - return new DataResponse([], Http::STATUS_FORBIDDEN); + throw new RemoteActivityException('', Http::STATUS_FORBIDDEN); } $path2 = null; if ($type === 'Move') { if (!isset($target['type'], $target['name']) || $target['type'] !== 'Document') { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('issue with move/target/type', Http::STATUS_BAD_REQUEST); } if (!isset($origin['type'], $origin['name']) || $origin['type'] !== 'Document') { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('issue with move/origin/type', Http::STATUS_BAD_REQUEST); } $path = $share['mountpoint'] . $target['name']; $path2 = $share['mountpoint'] . $origin['name']; } else { if (!isset($object['type'], $object['name']) || $object['type'] !== 'Document') { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('issue with object/type', Http::STATUS_BAD_REQUEST); } $path = $share['mountpoint'] . $object['name']; } $subject = $this->getSubject($type, $path, $path2); if ($subject === '') { - return new DataResponse([], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('empty subject', Http::STATUS_BAD_REQUEST); } $userFolder = $this->rootFolder->getUserFolder($user->getUID()); @@ -156,22 +209,16 @@ public function receiveActivity($token, array $to, array $actor, $type, $updated $storage = $node->getStorage(); if (!$storage->instanceOfStorage(ExternalStorage::class)) { - $response = new DataResponse([], Http::STATUS_FORBIDDEN); - $response->throttle(); - return $response; + throw new RemoteActivityException('incompatible storage', Http::STATUS_FORBIDDEN); } while ($storage instanceof Wrapper) { $storage = $storage->getWrapperStorage(); } if (!($storage instanceof ExternalStorage) || $storage->getToken() !== $token) { - $response = new DataResponse([], Http::STATUS_FORBIDDEN); - $response->throttle(); - return $response; + throw new RemoteActivityException('incompatible token', Http::STATUS_FORBIDDEN); } - } catch (NotFoundException|InvalidPathException) { - $response = new DataResponse([], Http::STATUS_NOT_FOUND); - $response->throttle(); - return $response; + } catch (NotFoundException|InvalidPathException $e) { + throw new RemoteActivityException('node not found', Http::STATUS_NOT_FOUND, $e); } if ($path2 !== null) { @@ -188,6 +235,7 @@ public function receiveActivity($token, array $to, array $actor, $type, $updated $subjectParams = [[$fileId => $path], $actor['name']]; } + $this->logger->debug('generating new activity from remote instance', ['token' => $token, 'type' => $type, 'to' => $to, 'actor' => $actor, 'updated' => $updated, 'object' => $object, 'target' => $target, 'origin' => $origin]); $event = $this->activityManager->generateEvent(); try { $event->setAffectedUser($user->getUID()) @@ -199,12 +247,20 @@ public function receiveActivity($token, array $to, array $actor, $type, $updated ->setTimestamp($date->getTimestamp()); $this->activityManager->publish($event); } catch (\InvalidArgumentException) { - return new DataResponse(['activity'], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('activity', Http::STATUS_BAD_REQUEST, response: ['activity']); } catch (\BadMethodCallException) { - return new DataResponse(['sending'], Http::STATUS_BAD_REQUEST); + throw new RemoteActivityException('sending', Http::STATUS_BAD_REQUEST, response: ['sending']); } + } - return new DataResponse(); + protected function manageCollection(array $collection): void { + foreach ($collection as $item) { + try { + $this->manageActivity($item['share']['token'] ?? '', $item['type'] ?? [], $item['to'] ?? [], $item['actor'] ?? [], $item['updated'] ?? '', $item['object'] ?? [], $item['target'] ?? [], $item['origin'] ?? []); + } catch (RemoteActivityException $e) { + $this->logger->warning('issue with a collection item from a remote activity', ['exception' => $e]); + } + } } protected function getSubject(string $type, string $path, ?string $path2) { @@ -239,4 +295,19 @@ protected function translateType($type) { default => '', }; } + + /** + * returns 600, or a higher value if the remote server is trusted + */ + protected function calculateTTL(string $remote): int { + /** @psalm-suppress UndefinedClass \OCA\Federation\TrustedServers */ + if ($remote !== '' && $this->appManager->isEnabledForAnyone('federation') && class_exists(TrustedServers::class)) { + $trustedServers = Server::get(TrustedServers::class); + if ($trustedServers->isTrustedServer($remote)) { + return $this->appConfig->getAppValueInt(ConfigLexicon::TRUSTED_SERVER_TTL); + } + } + + return 600; + } } diff --git a/lib/Exception/RemoteActivityException.php b/lib/Exception/RemoteActivityException.php new file mode 100644 index 000000000..ea5c1a5da --- /dev/null +++ b/lib/Exception/RemoteActivityException.php @@ -0,0 +1,26 @@ +response; + } +} diff --git a/lib/FilesHooks.php b/lib/FilesHooks.php index 62859f6a5..b15a9403f 100644 --- a/lib/FilesHooks.php +++ b/lib/FilesHooks.php @@ -17,6 +17,7 @@ use OCP\Activity\IManager; use OCP\BackgroundJob\IJobList; use OCP\Constants; +use OCP\Federation\ICloudIdManager; use OCP\Files\Cache\IFileAccess; use OCP\Files\Config\ICachedMountFileInfo; use OCP\Files\Config\IMountProviderCollection; @@ -70,6 +71,7 @@ public function __construct( protected NotificationGenerator $notificationGenerator, protected ITagManager $tagManager, protected ?CirclesManager $teamManager, + protected readonly ICloudIdManager $cloudIdManager, private IFileAccess $fileAccess, private IMountProviderCollection $mountProviderCollection, ) { @@ -201,12 +203,18 @@ protected function addNotificationsForFileAction(Node $node, string $activityTyp } protected function generateRemoteActivity(array $remoteUsers, string $type, int $time, $actor, $ownerPath = false) { + $instances = []; foreach ($remoteUsers as $remoteUser => $info) { if ($actor === $remoteUser) { // Current user receives the notification on their own instance already continue; } + $instance = $this->cloudIdManager->resolveCloudId($remoteUser)->getRemote(); + if (!array_key_exists($instance, $instances)) { + $instances[$instance] = []; + } + $arguments = [ $remoteUser, $info['token'], @@ -220,8 +228,10 @@ protected function generateRemoteActivity(array $remoteUsers, string $type, int $arguments[] = $info['second_path']; } - \OCP\Server::get(IJobList::class)->add(RemoteActivity::class, $arguments); + $instances[$instance][] = $arguments; } + + \OCP\Server::get(IJobList::class)->add(RemoteActivity::class, ['instances' => $instances]); } /** diff --git a/tests/Controller/RemoteActivityControllerTest.php b/tests/Controller/RemoteActivityControllerTest.php index b75d123ff..bb085d2b7 100644 --- a/tests/Controller/RemoteActivityControllerTest.php +++ b/tests/Controller/RemoteActivityControllerTest.php @@ -18,6 +18,7 @@ use OCP\Activity\IManager as IActivityManager; use OCP\App\IAppManager; use OCP\AppFramework\Http; +use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Utility\ITimeFactory; use OCP\DB\IResult; use OCP\DB\QueryBuilder\IExpressionBuilder; @@ -36,6 +37,7 @@ use OCP\IUserManager; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; class RemoteActivityControllerTest extends TestCase { private IRequest&MockObject $request; @@ -47,7 +49,7 @@ class RemoteActivityControllerTest extends TestCase { private ICloudIdManager&MockObject $cloudIdManager; private ITimeFactory&MockObject $timeFactory; - private RemoteActivityController $controller; + private RemoteActivityController&MockObject $controller; protected function setUp(): void { parent::setUp(); @@ -62,17 +64,25 @@ protected function setUp(): void { $this->timeFactory = $this->createMock(ITimeFactory::class); $this->timeFactory->method('getTime')->willReturn(time()); - $this->controller = new RemoteActivityController( - 'activity', - $this->request, - $this->db, - $this->userManager, - $this->appManager, - $this->rootFolder, - $this->activityManager, - $this->cloudIdManager, - $this->timeFactory, - ); + $this->controller = $this->getMockBuilder(RemoteActivityController::class) + ->setConstructorArgs([ + 'activity', + $this->request, + $this->db, + $this->userManager, + $this->appManager, + $this->rootFolder, + $this->createMock(IAppConfig::class), + $this->activityManager, + $this->cloudIdManager, + $this->timeFactory, + $this->createMock(LoggerInterface::class), + ]) + ->onlyMethods(['calculateTTL']) // Tell PHPUnit to ONLY mock this method + ->getMock(); + + $this->controller->method('calculateTTL') + ->willReturn(600); } private function validTimestamp(): string { @@ -180,7 +190,12 @@ public static function dataInvalidTimestamps(): array { #[DataProvider('dataInvalidTimestamps')] public function testInvalidTimestamp(string $updated): void { $this->appManager->method('isEnabledForAnyone')->willReturn(true); - $this->db->expects($this->never())->method('getQueryBuilder'); + $this->userManager->method('get')->willReturn($this->createUserMock()); + $this->setupDbQueryMock([ + 'mountpoint' => '/SharedFolder', + 'remote' => 'https://remote.example.net/', + ]); + $this->db->expects($this->once())->method('getQueryBuilder'); $response = $this->controller->receiveActivity( 'token', diff --git a/tests/FilesHooksTest.php b/tests/FilesHooksTest.php index 570a22f95..ad33c7e59 100644 --- a/tests/FilesHooksTest.php +++ b/tests/FilesHooksTest.php @@ -33,6 +33,7 @@ use OCA\Files_Sharing\SharedMount; use OCP\Activity\IEvent; use OCP\Activity\IManager; +use OCP\Federation\ICloudIdManager; use OCP\Files\Cache\IFileAccess; use OCP\Files\Config\IMountProviderCollection; use OCP\Files\Config\IUserMountCache; @@ -79,6 +80,7 @@ class FilesHooksTest extends TestCase { protected TagManager&MockObject $tagManager; protected Tags&MockObject $tags; protected (OCA\Circles\CirclesManager&MockObject)|null $teamManager = null; + protected ICloudIdManager&MockObject $cloudIdManager; private IFileAccess&MockObject $fileAccess; private IMountProviderCollection&MockObject $mountProviderCollection; @@ -102,6 +104,7 @@ protected function setUp(): void { ->willReturn([]); $this->tagManager->method('load') ->willReturn($this->tags); + $this->cloudIdManager = $this->createMock(ICloudIdManager::class); $this->fileAccess = $this->createMock(IFileAccess::class); $this->mountProviderCollection = $this->createMock(IMountProviderCollection::class); @@ -137,6 +140,7 @@ protected function getFilesHooks(array $mockedMethods = [], ?string $user = 'use $this->notificationGenerator, $this->tagManager, $this->teamManager, + $this->cloudIdManager, $this->fileAccess, $this->mountProviderCollection, ]) @@ -160,6 +164,7 @@ protected function getFilesHooks(array $mockedMethods = [], ?string $user = 'use $this->notificationGenerator, $this->tagManager, $this->teamManager, + $this->cloudIdManager, $this->fileAccess, $this->mountProviderCollection, ); @@ -209,6 +214,7 @@ public function testFileCreate(string $currentUser, bool $isPublicShare, string $this->notificationGenerator, $this->tagManager, $this->teamManager, + $this->cloudIdManager, $this->fileAccess, $this->mountProviderCollection, ])