diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index 3fd82c774c240..3a778805d545b 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -61,6 +61,7 @@ 'OCA\\Files\\Controller\\DirectEditingViewController' => $baseDir . '/../lib/Controller/DirectEditingViewController.php', 'OCA\\Files\\Controller\\FilenamesController' => $baseDir . '/../lib/Controller/FilenamesController.php', 'OCA\\Files\\Controller\\OpenLocalEditorController' => $baseDir . '/../lib/Controller/OpenLocalEditorController.php', + 'OCA\\Files\\Controller\\OpenedController' => $baseDir . '/../lib/Controller/OpenedController.php', 'OCA\\Files\\Controller\\TemplateController' => $baseDir . '/../lib/Controller/TemplateController.php', 'OCA\\Files\\Controller\\TransferOwnershipController' => $baseDir . '/../lib/Controller/TransferOwnershipController.php', 'OCA\\Files\\Controller\\ViewController' => $baseDir . '/../lib/Controller/ViewController.php', diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 5c8eb91c4c2e5..c92f1b55ff3df 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -76,6 +76,7 @@ class ComposerStaticInitFiles 'OCA\\Files\\Controller\\DirectEditingViewController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingViewController.php', 'OCA\\Files\\Controller\\FilenamesController' => __DIR__ . '/..' . '/../lib/Controller/FilenamesController.php', 'OCA\\Files\\Controller\\OpenLocalEditorController' => __DIR__ . '/..' . '/../lib/Controller/OpenLocalEditorController.php', + 'OCA\\Files\\Controller\\OpenedController' => __DIR__ . '/..' . '/../lib/Controller/OpenedController.php', 'OCA\\Files\\Controller\\TemplateController' => __DIR__ . '/..' . '/../lib/Controller/TemplateController.php', 'OCA\\Files\\Controller\\TransferOwnershipController' => __DIR__ . '/..' . '/../lib/Controller/TransferOwnershipController.php', 'OCA\\Files\\Controller\\ViewController' => __DIR__ . '/..' . '/../lib/Controller/ViewController.php', diff --git a/apps/files/lib/Controller/OpenedController.php b/apps/files/lib/Controller/OpenedController.php new file mode 100644 index 0000000000000..2b68b342216e6 --- /dev/null +++ b/apps/files/lib/Controller/OpenedController.php @@ -0,0 +1,66 @@ +, array{}> + * @throws OCSNotFoundException The file or folder was not found + * + * 200: Open reported + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 100, period: 60)] + #[ApiRoute(verb: 'POST', url: '/api/v1/opened')] + public function opened(IUser $user, int $fileId): DataResponse { + $node = $this->rootFolder->getUserFolder($user->getUID())->getFirstNodeById($fileId); + + // Also a 404 if the node is not readable, to not leak its existence + if ($node === null || !$node->isReadable()) { + throw new OCSNotFoundException($this->l10n->t('The file cannot be found')); + } + + $this->eventDispatcher->dispatchTyped(new NodeOpenedEvent($node, $user)); + return new DataResponse([]); + } +} diff --git a/apps/files/openapi.json b/apps/files/openapi.json index 808a0e07a41a1..f387f34931655 100644 --- a/apps/files/openapi.json +++ b/apps/files/openapi.json @@ -2906,6 +2906,142 @@ } } } + }, + "/ocs/v2.php/apps/files/api/v1/opened": { + "post": { + "operationId": "opened-opened", + "summary": "Report that the current user opened a file or folder", + "description": "Clients call this when the user actually opens a node, not when they read its content (sync, previews, downloads). Apps can listen to it to know what the user works with. It is for statistics only: clients send it when they choose to, so it must not be relied on for access or auditing.", + "tags": [ + "opened" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "fileId" + ], + "properties": { + "fileId": { + "type": "integer", + "format": "int64", + "description": "ID of the opened file or folder" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Open reported", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "The file or folder was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/apps/files/tests/Controller/OpenedControllerTest.php b/apps/files/tests/Controller/OpenedControllerTest.php new file mode 100644 index 0000000000000..b1bf84a1833b1 --- /dev/null +++ b/apps/files/tests/Controller/OpenedControllerTest.php @@ -0,0 +1,82 @@ +user = $this->createMock(IUser::class); + $this->user->method('getUID')->willReturn('alice'); + + $this->userFolder = $this->createMock(IUserFolder::class); + $rootFolder = $this->createMock(IRootFolder::class); + $rootFolder->method('getUserFolder')->with('alice')->willReturn($this->userFolder); + + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + + $this->controller = new OpenedController( + 'files', + $this->createMock(IRequest::class), + $rootFolder, + $this->eventDispatcher, + $this->createMock(IL10N::class), + ); + } + + public function testDispatchesEvent(): void { + $file = $this->createMock(File::class); + $file->method('isReadable')->willReturn(true); + $this->userFolder->method('getFirstNodeById')->with(42)->willReturn($file); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(fn (NodeOpenedEvent $event): bool + => $event->getNode() === $file && $event->getUser() === $this->user)); + + $this->controller->opened($this->user, 42); + } + + public function testNotFound(): void { + $this->userFolder->method('getFirstNodeById')->with(42)->willReturn(null); + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->expectException(OCSNotFoundException::class); + $this->controller->opened($this->user, 42); + } + + public function testNotReadable(): void { + $file = $this->createMock(File::class); + $file->method('isReadable')->willReturn(false); + $this->userFolder->method('getFirstNodeById')->with(42)->willReturn($file); + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->expectException(OCSNotFoundException::class); + $this->controller->opened($this->user, 42); + } +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index b3b5b63fb3a1e..6010d5580da18 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -545,6 +545,7 @@ 'OCP\\Files\\Events\\Node\\NodeCopiedEvent' => $baseDir . '/lib/public/Files/Events/Node/NodeCopiedEvent.php', 'OCP\\Files\\Events\\Node\\NodeCreatedEvent' => $baseDir . '/lib/public/Files/Events/Node/NodeCreatedEvent.php', 'OCP\\Files\\Events\\Node\\NodeDeletedEvent' => $baseDir . '/lib/public/Files/Events/Node/NodeDeletedEvent.php', + 'OCP\\Files\\Events\\Node\\NodeOpenedEvent' => $baseDir . '/lib/public/Files/Events/Node/NodeOpenedEvent.php', 'OCP\\Files\\Events\\Node\\NodeRenamedEvent' => $baseDir . '/lib/public/Files/Events/Node/NodeRenamedEvent.php', 'OCP\\Files\\Events\\Node\\NodeTouchedEvent' => $baseDir . '/lib/public/Files/Events/Node/NodeTouchedEvent.php', 'OCP\\Files\\Events\\Node\\NodeWrittenEvent' => $baseDir . '/lib/public/Files/Events/Node/NodeWrittenEvent.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 35c872fd7527d..a872edba351e7 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -586,6 +586,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Files\\Events\\Node\\NodeCopiedEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeCopiedEvent.php', 'OCP\\Files\\Events\\Node\\NodeCreatedEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeCreatedEvent.php', 'OCP\\Files\\Events\\Node\\NodeDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeDeletedEvent.php', + 'OCP\\Files\\Events\\Node\\NodeOpenedEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeOpenedEvent.php', 'OCP\\Files\\Events\\Node\\NodeRenamedEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeRenamedEvent.php', 'OCP\\Files\\Events\\Node\\NodeTouchedEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeTouchedEvent.php', 'OCP\\Files\\Events\\Node\\NodeWrittenEvent' => __DIR__ . '/../../..' . '/lib/public/Files/Events/Node/NodeWrittenEvent.php', diff --git a/lib/public/Files/Events/Node/NodeOpenedEvent.php b/lib/public/Files/Events/Node/NodeOpenedEvent.php new file mode 100644 index 0000000000000..41d9db9d255f3 --- /dev/null +++ b/lib/public/Files/Events/Node/NodeOpenedEvent.php @@ -0,0 +1,62 @@ +user; + } + + /** + * @since 36.0.0 + */ + #[\Override] + public function getWebhookSerializable(): array { + return [ + ...parent::getWebhookSerializable(), + 'user' => JsonSerializer::serializeUser($this->user), + ]; + } +} diff --git a/openapi.json b/openapi.json index 78f138e0ec809..6c26d3ac0f2ce 100644 --- a/openapi.json +++ b/openapi.json @@ -26755,6 +26755,142 @@ } } }, + "/ocs/v2.php/apps/files/api/v1/opened": { + "post": { + "operationId": "files-opened-opened", + "summary": "Report that the current user opened a file or folder", + "description": "Clients call this when the user actually opens a node, not when they read its content (sync, previews, downloads). Apps can listen to it to know what the user works with. It is for statistics only: clients send it when they choose to, so it must not be relied on for access or auditing.", + "tags": [ + "files/opened" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "fileId" + ], + "properties": { + "fileId": { + "type": "integer", + "format": "int64", + "description": "ID of the opened file or folder" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Open reported", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "404": { + "description": "The file or folder was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/files_external/api/v1/mounts": { "get": { "operationId": "files_external-api-get-user-mounts",