From 53edf7085fd08f786ad02cd1cf3a362968185399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:42:22 +0100 Subject: [PATCH 1/3] feat(files): add an API to report that a user opened a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a file is not opening it: sync clients, previews and downloads all fire BeforeNodeReadEvent. Clients can now call POST /ocs/v2.php/apps/files/api/v1/opened when the user actually opens a file or folder, which dispatches a NodeOpenedEvent apps can listen to. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + .../files/lib/Controller/OpenedController.php | 65 +++++++++ apps/files/openapi.json | 136 ++++++++++++++++++ .../tests/Controller/OpenedControllerTest.php | 82 +++++++++++ lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + .../Files/Events/Node/NodeOpenedEvent.php | 55 +++++++ openapi.json | 136 ++++++++++++++++++ 9 files changed, 478 insertions(+) create mode 100644 apps/files/lib/Controller/OpenedController.php create mode 100644 apps/files/tests/Controller/OpenedControllerTest.php create mode 100644 lib/public/Files/Events/Node/NodeOpenedEvent.php 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..5528fa64a0206 --- /dev/null +++ b/apps/files/lib/Controller/OpenedController.php @@ -0,0 +1,65 @@ +, 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..8f486be159979 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.", + "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..f42f3dc41dc19 --- /dev/null +++ b/lib/public/Files/Events/Node/NodeOpenedEvent.php @@ -0,0 +1,55 @@ +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..505c684ed47d1 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.", + "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", From 893d418d78bd2dee79475e837a1ca02a1468df31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:07:49 +0100 Subject: [PATCH 2/3] chore(files): mark NodeOpenedEvent as listenable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- lib/public/Files/Events/Node/NodeOpenedEvent.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/public/Files/Events/Node/NodeOpenedEvent.php b/lib/public/Files/Events/Node/NodeOpenedEvent.php index f42f3dc41dc19..b336f4a6a2a95 100644 --- a/lib/public/Files/Events/Node/NodeOpenedEvent.php +++ b/lib/public/Files/Events/Node/NodeOpenedEvent.php @@ -9,6 +9,7 @@ namespace OCP\Files\Events\Node; +use OCP\AppFramework\Attribute\Listenable; use OCP\EventDispatcher\JsonSerializer; use OCP\Files\Node; use OCP\IUser; @@ -22,6 +23,7 @@ * * @since 36.0.0 */ +#[Listenable(since: '36.0.0')] class NodeOpenedEvent extends AbstractNodeEvent { /** * @since 36.0.0 From 7b2f1fdd52f0eccc82f8dab13da86e2b0693d6c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:40:01 +0100 Subject: [PATCH 3/3] docs(files): say the opened event is for statistics only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients report opens when they choose to, so the event can be missing, repeated, or sent for a node the user never looked at. Make clear it must not be used for permissions, access control or auditing. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- apps/files/lib/Controller/OpenedController.php | 3 ++- apps/files/openapi.json | 2 +- lib/public/Files/Events/Node/NodeOpenedEvent.php | 5 +++++ openapi.json | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/files/lib/Controller/OpenedController.php b/apps/files/lib/Controller/OpenedController.php index 5528fa64a0206..2b68b342216e6 100644 --- a/apps/files/lib/Controller/OpenedController.php +++ b/apps/files/lib/Controller/OpenedController.php @@ -39,7 +39,8 @@ public function __construct( * * 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. + * 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. * * @param int $fileId ID of the opened file or folder * diff --git a/apps/files/openapi.json b/apps/files/openapi.json index 8f486be159979..f387f34931655 100644 --- a/apps/files/openapi.json +++ b/apps/files/openapi.json @@ -2911,7 +2911,7 @@ "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.", + "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" ], diff --git a/lib/public/Files/Events/Node/NodeOpenedEvent.php b/lib/public/Files/Events/Node/NodeOpenedEvent.php index b336f4a6a2a95..41d9db9d255f3 100644 --- a/lib/public/Files/Events/Node/NodeOpenedEvent.php +++ b/lib/public/Files/Events/Node/NodeOpenedEvent.php @@ -21,6 +21,11 @@ * is read (sync, previews, downloads) but only when a client says the user * actually opened it. * + * It is meant for statistics only, like recommending the files a user works + * with. Clients report it when they choose to, so it can be missing, repeated + * or sent for a node the user never looked at. Never use it for permissions, + * access control, auditing, or anything else that has to be reliable. + * * @since 36.0.0 */ #[Listenable(since: '36.0.0')] diff --git a/openapi.json b/openapi.json index 505c684ed47d1..6c26d3ac0f2ce 100644 --- a/openapi.json +++ b/openapi.json @@ -26759,7 +26759,7 @@ "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.", + "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" ],