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
14 changes: 0 additions & 14 deletions apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,6 @@ public function getPluginName(): string {
return 'checksumupdate';
}

/** @return string[] */
public function getHTTPMethods($path): array {
$tree = $this->server->tree;

if ($tree->nodeExists($path)) {
$node = $tree->getNodeForPath($path);
if ($node instanceof File) {
return ['PATCH'];
}
}

return [];
}

/** @return string[] */
public function getFeatures(): array {
return ['nextcloud-checksum-update'];
Expand Down
11 changes: 6 additions & 5 deletions apps/dav/lib/Connector/Sabre/Directory.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,14 @@ public function createDirectory($name) {
public function getChild($name, $info = null, ?IRequest $request = null, ?IL10N $l10n = null) {
$storage = $this->info->getStorage();
$allowDirectory = false;

// Checking if we're in a file drop
// If we are, then only PUT and MKCOL are allowed (see plugin)
// so we are safe to return the directory without a risk of
// leaking files and folders structure.
if ($storage instanceof PublicShareWrapper) {
$share = $storage->getShare();
$allowDirectory =
// Only allow directories for file drops
($share->getPermissions() & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ &&
// And only allow it for directories which are a direct child of the share root
$this->info->getId() === $share->getNodeId();
$allowDirectory = ($share->getPermissions() & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ;
}

// For file drop we need to be allowed to read the directory with the nickname
Expand Down
125 changes: 102 additions & 23 deletions apps/dav/lib/Files/Sharing/FilesDropPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,57 +36,136 @@ public function enable(): void {

/**
* This initializes the plugin.
*
* @param \Sabre\DAV\Server $server Sabre server
*
* @return void
* @throws MethodNotAllowed
* It is ONLY initialized by the server on a file drop request.
*/
public function initialize(\Sabre\DAV\Server $server): void {
$server->on('beforeMethod:*', [$this, 'beforeMethod'], 999);
$server->on('method:MKCOL', [$this, 'onMkcol']);
$this->enabled = false;
}

public function beforeMethod(RequestInterface $request, ResponseInterface $response): void {
public function onMkcol(RequestInterface $request, ResponseInterface $response) {
if (!$this->enabled || $this->share === null || $this->view === null) {
return;
}

// Only allow file drop
// If this is a folder creation request we need
// to fake a success so we can pretend every
// folder now exists.
$response->setStatus(201);
return false;
}

public function beforeMethod(RequestInterface $request, ResponseInterface $response) {
if (!$this->enabled || $this->share === null || $this->view === null) {
return;
}

// Retrieve the nickname from the request
$nickname = $request->hasHeader('X-NC-Nickname')
? trim(urldecode($request->getHeader('X-NC-Nickname')))
: null;

//
if ($request->getMethod() !== 'PUT') {
throw new MethodNotAllowed('Only PUT is allowed on files drop');
// If uploading subfolders we need to ensure they get created
// within the nickname folder
if ($request->getMethod() === 'MKCOL') {
if (!$nickname) {
throw new MethodNotAllowed('A nickname header is required when uploading subfolders');
}
} else {
throw new MethodNotAllowed('Only PUT is allowed on files drop');
}
}

// If this is a folder creation request
// let's stop there and let the onMkcol handle it
if ($request->getMethod() === 'MKCOL') {
return;
}

// Always upload at the root level
$path = explode('/', $request->getPath());
$path = array_pop($path);
// Now if we create a file, we need to create the
// full path along the way. We'll only handle conflict
// resolution on file conflicts, but not on folders.

// e.g files/dCP8yn3N86EK9sL/Folder/image.jpg
$path = $request->getPath();
$token = $this->share->getToken();

// e.g files/dCP8yn3N86EK9sL
$rootPath = substr($path, 0, strpos($path, $token) + strlen($token));
// e.g /Folder/image.jpg
$relativePath = substr($path, strlen($rootPath));
$isRootUpload = substr_count($relativePath, '/') === 1;

// Extract the attributes for the file request
$isFileRequest = false;
$attributes = $this->share->getAttributes();
$nickName = $request->hasHeader('X-NC-Nickname') ? urldecode($request->getHeader('X-NC-Nickname')) : null;
if ($attributes !== null) {
$isFileRequest = $attributes->getAttribute('fileRequest', 'enabled') === true;
}

// We need a valid nickname for file requests
if ($isFileRequest && ($nickName == null || trim($nickName) === '')) {
throw new MethodNotAllowed('Nickname is required for file requests');
if ($isFileRequest && !$nickname) {
throw new MethodNotAllowed('A nickname header is required for file requests');
}

// If this is a file request we need to create a folder for the user
if ($isFileRequest) {
// Check if the folder already exists
if (!($this->view->file_exists($nickName) === true)) {
$this->view->mkdir($nickName);
}
// We're only allowing the upload of
// long path with subfolders if a nickname is set.
// This prevents confusion when uploading files and help
// classify them by uploaders.
if (!$nickname && !$isRootUpload) {
throw new MethodNotAllowed('A nickname header is required when uploading subfolders');
}

// If we have a nickname, let's put everything inside
if ($nickname) {
// Put all files in the subfolder
$path = $nickName . '/' . $path;
$relativePath = '/' . $nickname . '/' . $relativePath;
$relativePath = str_replace('//', '/', $relativePath);
}

$newName = \OC_Helper::buildNotExistingFileNameForView('/', $path, $this->view);
$url = $request->getBaseUrl() . '/files/' . $this->share->getToken() . $newName;
// Create the folders along the way
$folders = $this->getPathSegments(dirname($relativePath));
foreach ($folders as $folder) {
if ($folder === '') {
continue;
} // skip empty parts
if (!$this->view->file_exists($folder)) {
$this->view->mkdir($folder);
}
}

// Finally handle conflicts on the end files
$noConflictPath = \OC_Helper::buildNotExistingFileNameForView(dirname($relativePath), basename($relativePath), $this->view);
$path = '/files/' . $token . '/' . $noConflictPath;
$url = $request->getBaseUrl() . str_replace('//', '/', $path);
$request->setUrl($url);
}

private function getPathSegments(string $path): array {
// Normalize slashes and remove trailing slash
$path = rtrim(str_replace('\\', '/', $path), '/');

// Handle absolute paths starting with /
$isAbsolute = str_starts_with($path, '/');

$segments = explode('/', $path);

// Add back the leading slash for the first segment if needed
$result = [];
$current = $isAbsolute ? '/' : '';

foreach ($segments as $segment) {
if ($segment === '') {
// skip empty parts
continue;
}
$current = rtrim($current, '/') . '/' . $segment;
$result[] = $current;
}

return $result;
}
}
108 changes: 100 additions & 8 deletions apps/dav/tests/unit/Files/Sharing/FilesDropPluginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,6 @@ protected function setUp(): void {
$this->request = $this->createMock(RequestInterface::class);
$this->response = $this->createMock(ResponseInterface::class);

$this->response->expects($this->never())
->method($this->anything());

$attributes = $this->createMock(IAttributes::class);
$this->share->expects($this->any())
->method('getAttributes')
Expand All @@ -60,13 +57,19 @@ protected function setUp(): void {
}

public function testInitialize(): void {
$this->server->expects($this->once())
$this->server->expects($this->at(0))
->method('on')
->with(
$this->equalTo('beforeMethod:*'),
$this->equalTo([$this->plugin, 'beforeMethod']),
$this->equalTo(999)
);
$this->server->expects($this->at(1))
->method('on')
->with(
$this->equalTo('method:MKCOL'),
$this->equalTo([$this->plugin, 'onMkcol']),
);

$this->plugin->initialize($this->server);
}
Expand Down Expand Up @@ -136,7 +139,7 @@ public function testFileAlreadyExistsValid(): void {
$this->plugin->beforeMethod($this->request, $this->response);
}

public function testNoMKCOL(): void {
public function testNoMKCOLWithoutNickname(): void {
$this->plugin->enable();
$this->plugin->setView($this->view);
$this->plugin->setShare($this->share);
Expand All @@ -149,14 +152,41 @@ public function testNoMKCOL(): void {
$this->plugin->beforeMethod($this->request, $this->response);
}

public function testNoSubdirPut(): void {
public function testMKCOLWithNickname(): void {
$this->plugin->enable();
$this->plugin->setView($this->view);
$this->plugin->setShare($this->share);

$this->request->method('getMethod')
->willReturn('MKCOL');

$this->request->method('hasHeader')
->with('X-NC-Nickname')
->willReturn(true);
$this->request->method('getHeader')
->with('X-NC-Nickname')
->willReturn('nickname');

$this->expectNotToPerformAssertions();

$this->plugin->beforeMethod($this->request, $this->response);
}

public function testSubdirPut(): void {
$this->plugin->enable();
$this->plugin->setView($this->view);
$this->plugin->setShare($this->share);

$this->request->method('getMethod')
->willReturn('PUT');

$this->request->method('hasHeader')
->with('X-NC-Nickname')
->willReturn(true);
$this->request->method('getHeader')
->with('X-NC-Nickname')
->willReturn('nickname');

$this->request->method('getPath')
->willReturn('/files/token/folder/file.txt');

Expand All @@ -165,7 +195,7 @@ public function testNoSubdirPut(): void {

$this->view->method('file_exists')
->willReturnCallback(function ($path) {
if ($path === 'file.txt' || $path === '/file.txt') {
if ($path === 'file.txt' || $path === '/folder/file.txt') {
return true;
} else {
return false;
Expand All @@ -174,8 +204,70 @@ public function testNoSubdirPut(): void {

$this->request->expects($this->once())
->method('setUrl')
->with($this->equalTo('https://example.com/files/token/file (2).txt'));
->with($this->equalTo('https://example.com/files/token/nickname/folder/file.txt'));

$this->plugin->beforeMethod($this->request, $this->response);
}

public function testRecursiveFolderCreation(): void {
$this->plugin->enable();
$this->plugin->setView($this->view);
$this->plugin->setShare($this->share);

$this->request->method('getMethod')
->willReturn('PUT');
$this->request->method('hasHeader')
->with('X-NC-Nickname')
->willReturn(true);
$this->request->method('getHeader')
->with('X-NC-Nickname')
->willReturn('nickname');

$this->request->method('getPath')
->willReturn('/files/token/folder/subfolder/file.txt');
$this->request->method('getBaseUrl')
->willReturn('https://example.com');
$this->view->method('file_exists')
->willReturn(false);

$this->view->expects($this->exactly(4))
->method('file_exists')
->withConsecutive(
['/nickname'],
['/nickname/folder'],
['/nickname/folder/subfolder'],
['/nickname/folder/subfolder/file.txt']
)
->willReturnOnConsecutiveCalls(
false,
false,
false,
false,
);
$this->view->expects($this->exactly(3))
->method('mkdir')
->withConsecutive(
['/nickname'],
['/nickname/folder'],
['/nickname/folder/subfolder'],
);

$this->request->expects($this->once())
->method('setUrl')
->with($this->equalTo('https://example.com/files/token/nickname/folder/subfolder/file.txt'));
$this->plugin->beforeMethod($this->request, $this->response);
}

public function testOnMkcol(): void {
$this->plugin->enable();
$this->plugin->setView($this->view);
$this->plugin->setShare($this->share);

$this->response->expects($this->once())
->method('setStatus')
->with(201);

$response = $this->plugin->onMkcol($this->request, $this->response);
$this->assertFalse($response);
}
}
Loading
Loading