Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/files/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/files/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
66 changes: 66 additions & 0 deletions apps/files/lib/Controller/OpenedController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Files\Controller;

use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Events\Node\NodeOpenedEvent;
use OCP\Files\IRootFolder;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUser;

final class OpenedController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private readonly IRootFolder $rootFolder,
private readonly IEventDispatcher $eventDispatcher,
private readonly IL10N $l10n,
) {
parent::__construct($appName, $request);
}

/**
* Report that the current user opened a file or folder
*
* 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.
*
* @param int $fileId ID of the opened file or folder
*
* @return DataResponse<Http::STATUS_OK, list<empty>, 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([]);
}
}
136 changes: 136 additions & 0 deletions apps/files/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
Expand Down
82 changes: 82 additions & 0 deletions apps/files/tests/Controller/OpenedControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Files\Tests\Controller;

use OCA\Files\Controller\OpenedController;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Events\Node\NodeOpenedEvent;
use OCP\Files\File;
use OCP\Files\IRootFolder;
use OCP\Files\IUserFolder;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUser;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class OpenedControllerTest extends TestCase {
private IUserFolder&MockObject $userFolder;
private IEventDispatcher&MockObject $eventDispatcher;
private IUser&MockObject $user;
private OpenedController $controller;

protected function setUp(): void {
parent::setUp();

$this->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);
}
}
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
62 changes: 62 additions & 0 deletions lib/public/Files/Events/Node/NodeOpenedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCP\Files\Events\Node;

use OCP\AppFramework\Attribute\Listenable;
use OCP\EventDispatcher\JsonSerializer;
use OCP\Files\Node;
use OCP\IUser;

/**
* A user opened a node in a client, as reported by that client.
*
* Unlike {@see BeforeNodeReadEvent}, this is not dispatched when the content
* 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
*/
Comment thread
skjnldsv marked this conversation as resolved.
Resolved
#[Listenable(since: '36.0.0')]
class NodeOpenedEvent extends AbstractNodeEvent {
/**
* @since 36.0.0
*/
public function __construct(
Node $node,
private IUser $user,
) {
parent::__construct($node);
}

/**
* The user who opened the node
*
* @since 36.0.0
*/
public function getUser(): IUser {
return $this->user;
}

/**
* @since 36.0.0
*/
#[\Override]
public function getWebhookSerializable(): array {
return [
...parent::getWebhookSerializable(),
'user' => JsonSerializer::serializeUser($this->user),
];
}
}
Loading
Loading