feat: add oc-ownerid and oc-permissions headers on PUT DAV requests

Signed-off-by: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com>
pull/54542/head
Salvatore Martire 2025-08-20 17:10:13 +07:00
parent 343e8236a1
commit 0b577efcef
6 changed files with 204 additions and 0 deletions

@ -209,6 +209,7 @@ return array(
'OCA\\DAV\\ConfigLexicon' => $baseDir . '/../lib/ConfigLexicon.php',
'OCA\\DAV\\Connector\\LegacyDAVACL' => $baseDir . '/../lib/Connector/LegacyDAVACL.php',
'OCA\\DAV\\Connector\\LegacyPublicAuth' => $baseDir . '/../lib/Connector/LegacyPublicAuth.php',
'OCA\\DAV\\Connector\\Sabre\\AddExtraHeadersPlugin' => $baseDir . '/../lib/Connector/Sabre/AddExtraHeadersPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\AnonymousOptionsPlugin' => $baseDir . '/../lib/Connector/Sabre/AnonymousOptionsPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\AppleQuirksPlugin' => $baseDir . '/../lib/Connector/Sabre/AppleQuirksPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\Auth' => $baseDir . '/../lib/Connector/Sabre/Auth.php',

@ -224,6 +224,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\ConfigLexicon' => __DIR__ . '/..' . '/../lib/ConfigLexicon.php',
'OCA\\DAV\\Connector\\LegacyDAVACL' => __DIR__ . '/..' . '/../lib/Connector/LegacyDAVACL.php',
'OCA\\DAV\\Connector\\LegacyPublicAuth' => __DIR__ . '/..' . '/../lib/Connector/LegacyPublicAuth.php',
'OCA\\DAV\\Connector\\Sabre\\AddExtraHeadersPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/AddExtraHeadersPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\AnonymousOptionsPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/AnonymousOptionsPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\AppleQuirksPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/AppleQuirksPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\Auth' => __DIR__ . '/..' . '/../lib/Connector/Sabre/Auth.php',

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Connector\Sabre;
use Psr\Log\LoggerInterface;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\Server;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
/**
* Adds the "OC-OwnerId" and "OC-Permissions" after PUT requests so that
* clients don't need to do a propfind after uploading a file to decide what
* to display.
*/
class AddExtraHeadersPlugin extends \Sabre\DAV\ServerPlugin {
private ?Server $server = null;
public function __construct(
private LoggerInterface $logger,
private bool $isPublic = false,
) {
}
public function initialize(Server $server): void {
$this->server = $server;
$server->on('afterMethod:PUT', $this->afterPut(...));
}
private function afterPut(RequestInterface $request, ResponseInterface $response): void {
if ($this->server === null) {
return;
}
$node = null;
try {
$node = $this->server->tree->getNodeForPath($request->getPath());
} catch (NotFound) {
$this->logger->error("Cannot set extra headers for non-existing file '{$request->getPath()}'");
return;
}
if (!$node instanceof Node) {
$nodeType = get_debug_type($node);
$this->logger->error("Cannot set extra headers for node of type {$nodeType} for file '{$request->getPath()}'");
return;
}
if (!$this->isPublic) {
$ownerId = $node->getOwner()?->getUID();
if ($ownerId !== null) {
$response->setHeader('X-NC-OwnerId', $ownerId);
}
}
$permissions = $this->isPublic ? $node->getPublicDavPermissions()
: $node->getDavPermissions();
$response->setHeader('X-NC-Permissions', $permissions);
}
}

@ -209,6 +209,7 @@ class ServerFactory {
);
}
$server->addPlugin(new CopyEtagHeaderPlugin());
$server->addPlugin(new AddExtraHeadersPlugin($this->logger, $isPublicShare));
// Load dav plugins from apps
$event = new SabrePluginEvent($server);

@ -27,6 +27,7 @@ use OCA\DAV\CardDAV\PhotoCache;
use OCA\DAV\CardDAV\Security\CardDavRateLimitingPlugin;
use OCA\DAV\CardDAV\Validation\CardDavValidatePlugin;
use OCA\DAV\Comments\CommentsPlugin;
use OCA\DAV\Connector\Sabre\AddExtraHeadersPlugin;
use OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin;
use OCA\DAV\Connector\Sabre\AppleQuirksPlugin;
use OCA\DAV\Connector\Sabre\Auth;
@ -384,6 +385,7 @@ class Server {
)
);
}
$this->server->addPlugin(new AddExtraHeadersPlugin($logger, false));
$this->server->addPlugin(new EnablePlugin(
\OCP\Server::get(IConfig::class),
\OCP\Server::get(BirthdayService::class),

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace unit\Connector\Sabre;
use LogicException;
use OCA\DAV\Connector\Sabre\AddExtraHeadersPlugin;
use OCA\DAV\Connector\Sabre\Node;
use OCA\DAV\Connector\Sabre\Server;
use OCP\IUser;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\Tree;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Test\TestCase;
class AddExtraHeadersPluginTest extends TestCase {
private AddExtraHeadersPlugin $plugin;
private Server&MockObject $server;
private LoggerInterface&MockObject $logger;
private RequestInterface&MockObject $request;
private ResponseInterface&MockObject $response;
private Tree&MockObject $tree;
public static function afterPutData(): array {
return [
'owner and permissions present' => [
'user', true, 'PERMISSIONS', true, 2
],
'permissions only' => [
null, false, 'PERMISSIONS', true, 1
],
];
}
public function testAfterPutNotFoundException(): void {
$afterPut = null;
$this->server->expects($this->once())
->method('on')
->willReturnCallback(
function ($method, $callback) use (&$afterPut) {
$this->assertSame('afterMethod:PUT', $method);
$afterPut = $callback;
});
$this->plugin->initialize($this->server);
$node = $this->createMock(Node::class);
$this->tree->expects($this->once())->method('getNodeForPath')
->willThrowException(new NotFound());
$this->logger->expects($this->once())->method('error');
$afterPut($this->request, $this->response);
}
#[DataProvider('afterPutData')]
public function testAfterPut(?string $ownerId, bool $expectOwnerIdHeader,
?string $permissions, bool $expectPermissionsHeader,
int $expectedInvocations): void {
$afterPut = null;
$this->server->expects($this->once())
->method('on')
->willReturnCallback(
function ($method, $callback) use (&$afterPut) {
$this->assertSame('afterMethod:PUT', $method);
$afterPut = $callback;
});
$this->plugin->initialize($this->server);
$node = $this->createMock(Node::class);
$this->tree->expects($this->once())->method('getNodeForPath')
->willReturn($node);
$user = $this->createMock(IUser::class);
$node->expects($this->once())->method('getOwner')->willReturn($user);
$user->expects($this->once())->method('getUID')->willReturn($ownerId);
$node->expects($this->once())->method('getDavPermissions')->willReturn($permissions);
$matcher = $this->exactly($expectedInvocations);
$this->response->expects($matcher)->method('setHeader')
->willReturnCallback(function ($name, $value) use (
$expectedInvocations,
$expectPermissionsHeader,
$expectOwnerIdHeader,
$matcher,
$ownerId, $permissions) {
$invocationNumber = $matcher->numberOfInvocations();
if ($invocationNumber === 0) {
throw new LogicException('No invocations were expected');
}
if (($expectOwnerIdHeader && $expectedInvocations === 1)
|| ($expectedInvocations
=== 2 && $invocationNumber === 1)) {
$this->assertEquals('X-NC-OwnerId', $name);
$this->assertEquals($ownerId, $value);
}
if (($expectPermissionsHeader && $expectedInvocations === 1)
|| ($expectedInvocations
=== 2 && $invocationNumber === 2)) {
$this->assertEquals('X-NC-Permissions', $name);
$this->assertEquals($permissions, $value);
}
});
$afterPut($this->request, $this->response);
}
protected function setUp(): void {
parent::setUp();
$this->server = $this->createMock(Server::class);
$this->tree = $this->createMock(Tree::class);
$this->server->tree = $this->tree;
$this->logger = $this->createMock(LoggerInterface::class);
$this->plugin = new AddExtraHeadersPlugin($this->logger, false);
$this->request = $this->createMock(RequestInterface::class);
$this->response = $this->createMock(ResponseInterface::class);
}
}