nextcloud/apps/files_versions/lib/Sabre/Plugin.php
Josh 183136d166
chore: Fix comments and formatting in Plugin.php
Signed-off-by: Josh <josh.t.richards@gmail.com>
2025-12-26 19:07:13 -05:00

160 lines
5.2 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2019-2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_Versions\Sabre;
use OC\AppFramework\Http\Request;
use OCA\DAV\Connector\Sabre\FilesPlugin;
use OCP\IPreview;
use OCP\IRequest;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\INode;
use Sabre\DAV\PropFind;
use Sabre\DAV\PropPatch;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
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 {
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';
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 readonly IRequest $request,
private readonly IPreview $previewManager,
) {
}
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']);
}
/**
* 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/')) {
return;
}
try {
$node = $this->server->tree->getNodeForPath($path);
} catch (NotFound $e) {
return;
}
if (!($node instanceof VersionFile)) {
return;
}
$filename = $node->getVersion()->getSourceFileName();
$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)) {
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',
);
}
/**
* 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)) {
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) . '"');
}
}
}