Skip to content
Open
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
131 changes: 96 additions & 35 deletions apps/files_versions/lib/Sabre/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2019-2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_Versions\Sabre;
Expand All @@ -21,35 +21,48 @@
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;

/**
* SabreDAV plugin for managing versioned file access and metadata.
*
* Handles WebDAV requests related to file versions, including download headers,
* version metadata properties, and compatibility for various clients and browsers.
*/
class Plugin extends ServerPlugin {
private Server $server;

public const LABEL = 'label';

public const AUTHOR = 'author';

public const VERSION_LABEL = '{http://nextcloud.org/ns}version-label';

public const VERSION_AUTHOR = '{http://nextcloud.org/ns}version-author'; // dav property for author
public const VERSION_AUTHOR = '{http://nextcloud.org/ns}version-author';
private const LEGACY_FILENAME_HEADER_USER_AGENTS = [ // Quirky clients
Request::USER_AGENT_IE,
Request::USER_AGENT_ANDROID_MOBILE_CHROME,
Request::USER_AGENT_FREEBOX,
];
private Server $server;

public function __construct(
private IRequest $request,
private IPreview $previewManager,
private readonly IRequest $request,
private readonly IPreview $previewManager,
) {
$this->request = $request;
}

public function initialize(Server $server) {
public function initialize(Server $server): void {
$this->server = $server;

$server->on('afterMethod:GET', [$this, 'afterGet']);
$server->on('propFind', [$this, 'propFind']);
$server->on('propPatch', [$this, 'propPatch']);
}

public function afterGet(RequestInterface $request, ResponseInterface $response) {
/**
* Handles the GET request for versioned files.
*
* Validates the request path, checks node type, and sets appropriate download headers
* to ensure compatibility across different clients and browsers.
*/
public function afterGet(RequestInterface $request, ResponseInterface $response): void {
$path = $request->getPath();
if (!str_starts_with($path, 'versions')) {

if (!str_starts_with($path, 'versions/')) {
return;
}

Expand All @@ -64,36 +77,84 @@ public function afterGet(RequestInterface $request, ResponseInterface $response)
}

$filename = $node->getVersion()->getSourceFileName();

if ($this->request->isUserAgent(
[
Request::USER_AGENT_IE,
Request::USER_AGENT_ANDROID_MOBILE_CHROME,
Request::USER_AGENT_FREEBOX,
])) {
$response->addHeader('Content-Disposition', 'attachment; filename="' . rawurlencode($filename) . '"');
} else {
$response->addHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . rawurlencode($filename)
. '; filename="' . rawurlencode($filename) . '"');
}
$this->addContentDispositionHeader($response, $filename);
}

/**
* WebDAV PROPFIND event handler for versioned files.
*
* Provides read-only access to version-related information if the
* current node is a VersionFile.
*/
public function propFind(PropFind $propFind, INode $node): void {
if ($node instanceof VersionFile) {
$propFind->handle(self::VERSION_LABEL, fn () => $node->getMetadataValue(self::LABEL));
$propFind->handle(self::VERSION_AUTHOR, fn () => $node->getMetadataValue(self::AUTHOR));
$propFind->handle(
FilesPlugin::HAS_PREVIEW_PROPERTYNAME,
fn (): string => $this->previewManager->isMimeSupported($node->getContentType()) ? 'true' : 'false',
);
if (!($node instanceof VersionFile)) {
return;
}

$propFind->handle(
self::VERSION_LABEL,
fn () => $node->getMetadataValue(self::LABEL)
);
$propFind->handle(
self::VERSION_AUTHOR,
fn () => $node->getMetadataValue(self::AUTHOR)
);
$propFind->handle(
FilesPlugin::HAS_PREVIEW_PROPERTYNAME,
fn (): string => $this->previewManager->isMimeSupported($node->getContentType()) ? 'true' : 'false',
);
}

public function propPatch($path, PropPatch $propPatch): void {
/**
* WebDAV PROPPATCH event handler for versioned files.
*
* Updates version related properties on VersionFile nodes.
*/
public function propPatch(string $path, PropPatch $propPatch): void {
$node = $this->server->tree->getNodeForPath($path);

if ($node instanceof VersionFile) {
$propPatch->handle(self::VERSION_LABEL, fn (string $label) => $node->setMetadataValue(self::LABEL, $label));
if (!($node instanceof VersionFile)) {
return;
}

$propPatch->handle(
self::VERSION_LABEL,
fn (string $label) => $node->setMetadataValue(self::LABEL, $label)
);
}

/**
* Add a Content-Disposition header in a way that attempts to be broadly compatible with various user agents.
*
* Sends both 'filename' (legacy quoted) and 'filename*' (UTF-8 encoded) per RFC 6266,
* except for known quirky agents known to mishandle the `filename*`, which only get `filename`.
*
* Note: The quoting/escaping should strictly follow RFC 6266 and RFC 5987.
*
* TODO: Currently uses rawurlencode($filename) for both parameters, which is wrong: filename= should be plain
* quoted ASCII (with necessary escaping), while filename* should be UTF-8 percent-encoded.
* TODO: This logic appears elsewhere (sometimes with different quoting/filename handling) and could benefit
* from a shared utility function. See Symfony example:
* - https://github.com/symfony/symfony/blob/175775eb21508becf7e7a16d65959488e522c39a/src/Symfony/Component/HttpFoundation/BinaryFileResponse.php#L146-L155
* - https://github.com/symfony/symfony/blob/175775eb21508becf7e7a16d65959488e522c39a/src/Symfony/Component/HttpFoundation/HeaderUtils.php#L152-L165
*
* @param ResponseInterface $response HTTP response object to add the header to
* @param string $filename Download filename
*/
private function addContentDispositionHeader(ResponseInterface $response, string $filename): void {
if (!$this->request->isUserAgent(self::LEGACY_FILENAME_HEADER_USER_AGENTS)) {
// Modern clients will use 'filename*'; older clients will refer to `filename`.
// The older fallback must be listed first per RFC.
// In theory this is all we actually need to handle both client types.
$response->addHeader(
'Content-Disposition',
'attachment; filename="' . rawurlencode($filename) . '"; filename*=UTF-8\'\'' . rawurlencode($filename)
);
} else {
// Quirky clients that choke on `filename*`: only send `filename=`
$response->addHeader(
'Content-Disposition',
'attachment; filename="' . rawurlencode($filename) . '"');
}
}
}
Loading