Revert "fix(performance): Do not set up filesystem on every call"

pull/36788/head
Joas Schilling 1 year ago committed by GitHub
parent 93e703bbfc
commit 98ed72b3ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -48,7 +48,6 @@ use OCP\Files\StorageNotAvailableException;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
use Psr\Log\LoggerInterface;
use Sabre\DAV\Exception;
use Sabre\DAV\Exception\BadRequest;
use Sabre\DAV\Exception\Locked;
use Sabre\DAV\Exception\NotFound;
@ -103,19 +102,33 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
* @param string $name Name of the file
* @param resource|string $data Initial payload
* @return null|string
* @throws Exception\EntityTooLarge
* @throws Exception\UnsupportedMediaType
* @throws FileLocked
* @throws InvalidPath
* @throws Exception
* @throws BadRequest
* @throws Exception\Forbidden
* @throws ServiceUnavailable
* @throws \Sabre\DAV\Exception
* @throws \Sabre\DAV\Exception\BadRequest
* @throws \Sabre\DAV\Exception\Forbidden
* @throws \Sabre\DAV\Exception\ServiceUnavailable
*/
public function createFile($name, $data = null) {
try {
// For non-chunked upload it is enough to check if we can create a new file
if (!$this->fileView->isCreatable($this->path)) {
throw new Exception\Forbidden();
// for chunked upload also updating a existing file is a "createFile"
// because we create all the chunks before re-assemble them to the existing file.
if (isset($_SERVER['HTTP_OC_CHUNKED'])) {
// exit if we can't create a new file and we don't updatable existing file
$chunkInfo = \OC_FileChunking::decodeName($name);
if (!$this->fileView->isCreatable($this->path) &&
!$this->fileView->isUpdatable($this->path . '/' . $chunkInfo['name'])
) {
throw new \Sabre\DAV\Exception\Forbidden();
}
} else {
// For non-chunked upload it is enough to check if we can create a new file
if (!$this->fileView->isCreatable($this->path)) {
throw new \Sabre\DAV\Exception\Forbidden();
}
}
$this->fileView->verifyPath($this->path, $name);
@ -140,8 +153,8 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
$this->fileView->unlockFile($path . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE);
$node->releaseLock(ILockingProvider::LOCK_SHARED);
return $result;
} catch (StorageNotAvailableException $e) {
throw new ServiceUnavailable($e->getMessage());
} catch (\OCP\Files\StorageNotAvailableException $e) {
throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), $e->getCode(), $e);
} catch (InvalidPathException $ex) {
throw new InvalidPath($ex->getMessage(), false, $ex);
} catch (ForbiddenException $ex) {
@ -157,22 +170,22 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
* @param string $name
* @throws FileLocked
* @throws InvalidPath
* @throws Exception\Forbidden
* @throws ServiceUnavailable
* @throws \Sabre\DAV\Exception\Forbidden
* @throws \Sabre\DAV\Exception\ServiceUnavailable
*/
public function createDirectory($name) {
try {
if (!$this->info->isCreatable()) {
throw new Exception\Forbidden();
throw new \Sabre\DAV\Exception\Forbidden();
}
$this->fileView->verifyPath($this->path, $name);
$newPath = $this->path . '/' . $name;
if (!$this->fileView->mkdir($newPath)) {
throw new Exception\Forbidden('Could not create directory ' . $newPath);
throw new \Sabre\DAV\Exception\Forbidden('Could not create directory ' . $newPath);
}
} catch (StorageNotAvailableException $e) {
throw new ServiceUnavailable($e->getMessage());
} catch (\OCP\Files\StorageNotAvailableException $e) {
throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage());
} catch (InvalidPathException $ex) {
throw new InvalidPath($ex->getMessage());
} catch (ForbiddenException $ex) {
@ -190,7 +203,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
* @return \Sabre\DAV\INode
* @throws InvalidPath
* @throws \Sabre\DAV\Exception\NotFound
* @throws ServiceUnavailable
* @throws \Sabre\DAV\Exception\ServiceUnavailable
*/
public function getChild($name, $info = null) {
if (!$this->info->isReadable()) {
@ -203,12 +216,12 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
try {
$this->fileView->verifyPath($this->path, $name);
$info = $this->fileView->getFileInfo($path);
} catch (StorageNotAvailableException $e) {
throw new ServiceUnavailable($e->getMessage());
} catch (\OCP\Files\StorageNotAvailableException $e) {
throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage());
} catch (InvalidPathException $ex) {
throw new InvalidPath($ex->getMessage());
} catch (ForbiddenException $e) {
throw new Exception\Forbidden();
throw new \Sabre\DAV\Exception\Forbidden();
}
}
@ -285,17 +298,17 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
*
* @return void
* @throws FileLocked
* @throws Exception\Forbidden
* @throws \Sabre\DAV\Exception\Forbidden
*/
public function delete() {
if ($this->path === '' || $this->path === '/' || !$this->info->isDeletable()) {
throw new Exception\Forbidden();
throw new \Sabre\DAV\Exception\Forbidden();
}
try {
if (!$this->fileView->rmdir($this->path)) {
// assume it wasn't possible to remove due to permission issue
throw new Exception\Forbidden();
throw new \Sabre\DAV\Exception\Forbidden();
}
} catch (ForbiddenException $ex) {
throw new Forbidden($ex->getMessage(), $ex->getRetry());
@ -330,7 +343,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
} catch (\OCP\Files\NotFoundException $e) {
$logger->warning("error while getting quota into", ['exception' => $e]);
return [0, 0];
} catch (StorageNotAvailableException $e) {
} catch (\OCP\Files\StorageNotAvailableException $e) {
$logger->warning("error while getting quota into", ['exception' => $e]);
return [0, 0];
} catch (NotPermittedException $e) {
@ -362,7 +375,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
* @throws ServiceUnavailable
* @throws Forbidden
* @throws FileLocked
* @throws Exception\Forbidden
* @throws \Sabre\DAV\Exception\Forbidden
*/
public function moveInto($targetName, $fullSourcePath, INode $sourceNode) {
if (!$sourceNode instanceof Node) {
@ -386,7 +399,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
// at getNodeForPath we also check the path for isForbiddenFileOrDir
// with that we have covered both source and destination
if ($sourceNode instanceof Directory && $targetNodeExists) {
throw new Exception\Forbidden('Could not copy directory ' . $sourceNode->getName() . ', target exists');
throw new \Sabre\DAV\Exception\Forbidden('Could not copy directory ' . $sourceNode->getName() . ', target exists');
}
[$sourceDir,] = \Sabre\Uri\split($sourceNode->getPath());
@ -407,11 +420,11 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
if ($targetNodeExists || $sameFolder) {
// note that renaming a share mount point is always allowed
if (!$this->fileView->isUpdatable($destinationDir) && !$isMovableMount) {
throw new Exception\Forbidden();
throw new \Sabre\DAV\Exception\Forbidden();
}
} else {
if (!$this->fileView->isCreatable($destinationDir)) {
throw new Exception\Forbidden();
throw new \Sabre\DAV\Exception\Forbidden();
}
}
@ -419,7 +432,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
// moving to a different folder, source will be gone, like a deletion
// note that moving a share mount point is always allowed
if (!$this->fileView->isDeletable($sourcePath) && !$isMovableMount) {
throw new Exception\Forbidden();
throw new \Sabre\DAV\Exception\Forbidden();
}
}
@ -432,7 +445,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
$renameOkay = $this->fileView->rename($sourcePath, $destinationPath);
if (!$renameOkay) {
throw new Exception\Forbidden('');
throw new \Sabre\DAV\Exception\Forbidden('');
}
} catch (StorageNotAvailableException $e) {
throw new ServiceUnavailable($e->getMessage());
@ -452,7 +465,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol
$sourcePath = $sourceNode->getPath();
if (!$this->fileView->isCreatable($this->getPath())) {
throw new Exception\Forbidden();
throw new \Sabre\DAV\Exception\Forbidden();
}
try {

@ -148,6 +148,15 @@ class File extends Node implements IFile {
// verify path of the target
$this->verifyPath();
// chunked handling
if (isset($_SERVER['HTTP_OC_CHUNKED'])) {
try {
return $this->createFileChunked($data);
} catch (\Exception $e) {
$this->convertToSabreException($e);
}
}
/** @var Storage $partStorage */
[$partStorage] = $this->fileView->resolvePath($this->path);
$needsPartFile = $partStorage->needsPartFile() && (strlen($this->path) > 1);
@ -568,6 +577,132 @@ class File extends Node implements IFile {
return $storage->getDirectDownload($internalPath);
}
/**
* @param resource $data
* @return null|string
* @throws Exception
* @throws BadRequest
* @throws NotImplemented
* @throws ServiceUnavailable
*/
private function createFileChunked($data) {
[$path, $name] = \Sabre\Uri\split($this->path);
$info = \OC_FileChunking::decodeName($name);
if (empty($info)) {
throw new NotImplemented($this->l10n->t('Invalid chunk name'));
}
$chunk_handler = new \OC_FileChunking($info);
$bytesWritten = $chunk_handler->store($info['index'], $data);
//detect aborted upload
if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'PUT') {
if (isset($_SERVER['CONTENT_LENGTH'])) {
$expected = (int)$_SERVER['CONTENT_LENGTH'];
if ($bytesWritten !== $expected) {
$chunk_handler->remove($info['index']);
throw new BadRequest(
$this->l10n->t(
'Expected filesize of %1$s but read (from Nextcloud client) and wrote (to Nextcloud storage) %2$s. Could either be a network problem on the sending side or a problem writing to the storage on the server side.',
[
$this->l10n->n('%n byte', '%n bytes', $expected),
$this->l10n->n('%n byte', '%n bytes', $bytesWritten),
],
)
);
}
}
}
if ($chunk_handler->isComplete()) {
/** @var Storage $storage */
[$storage,] = $this->fileView->resolvePath($path);
$needsPartFile = $storage->needsPartFile();
$partFile = null;
$targetPath = $path . '/' . $info['name'];
/** @var \OC\Files\Storage\Storage $targetStorage */
[$targetStorage, $targetInternalPath] = $this->fileView->resolvePath($targetPath);
$exists = $this->fileView->file_exists($targetPath);
try {
$this->fileView->lockFile($targetPath, ILockingProvider::LOCK_SHARED);
$this->emitPreHooks($exists, $targetPath);
$this->fileView->changeLock($targetPath, ILockingProvider::LOCK_EXCLUSIVE);
/** @var \OC\Files\Storage\Storage $targetStorage */
[$targetStorage, $targetInternalPath] = $this->fileView->resolvePath($targetPath);
if ($needsPartFile) {
// we first assembly the target file as a part file
$partFile = $this->getPartFileBasePath($path . '/' . $info['name']) . '.ocTransferId' . $info['transferid'] . '.part';
/** @var \OC\Files\Storage\Storage $targetStorage */
[$partStorage, $partInternalPath] = $this->fileView->resolvePath($partFile);
$chunk_handler->file_assemble($partStorage, $partInternalPath);
// here is the final atomic rename
$renameOkay = $targetStorage->moveFromStorage($partStorage, $partInternalPath, $targetInternalPath);
$fileExists = $targetStorage->file_exists($targetInternalPath);
if ($renameOkay === false || $fileExists === false) {
\OC::$server->get(LoggerInterface::class)->error('\OC\Files\Filesystem::rename() failed', ['app' => 'webdav']);
// only delete if an error occurred and the target file was already created
if ($fileExists) {
// set to null to avoid double-deletion when handling exception
// stray part file
$partFile = null;
$targetStorage->unlink($targetInternalPath);
}
$this->fileView->changeLock($targetPath, ILockingProvider::LOCK_SHARED);
throw new Exception($this->l10n->t('Could not rename part file assembled from chunks'));
}
} else {
// assemble directly into the final file
$chunk_handler->file_assemble($targetStorage, $targetInternalPath);
}
// allow sync clients to send the mtime along in a header
if (isset($this->request->server['HTTP_X_OC_MTIME'])) {
$mtime = $this->sanitizeMtime($this->request->server['HTTP_X_OC_MTIME']);
if ($targetStorage->touch($targetInternalPath, $mtime)) {
$this->header('X-OC-MTime: accepted');
}
}
// since we skipped the view we need to scan and emit the hooks ourselves
$targetStorage->getUpdater()->update($targetInternalPath);
$this->fileView->changeLock($targetPath, ILockingProvider::LOCK_SHARED);
$this->emitPostHooks($exists, $targetPath);
// FIXME: should call refreshInfo but can't because $this->path is not the of the final file
$info = $this->fileView->getFileInfo($targetPath);
if (isset($this->request->server['HTTP_OC_CHECKSUM'])) {
$checksum = trim($this->request->server['HTTP_OC_CHECKSUM']);
$this->fileView->putFileInfo($targetPath, ['checksum' => $checksum]);
} elseif ($info->getChecksum() !== null && $info->getChecksum() !== '') {
$this->fileView->putFileInfo($this->path, ['checksum' => '']);
}
$this->fileView->unlockFile($targetPath, ILockingProvider::LOCK_SHARED);
return $info->getEtag();
} catch (\Exception $e) {
if ($partFile !== null) {
$targetStorage->unlink($targetInternalPath);
}
$this->convertToSabreException($e);
}
}
return null;
}
/**
* Convert the given exception to a SabreException instance
*

@ -577,6 +577,15 @@ class FilesPlugin extends ServerPlugin {
* @throws \Sabre\DAV\Exception\BadRequest
*/
public function sendFileIdHeader($filePath, \Sabre\DAV\INode $node = null) {
// chunked upload handling
if (isset($_SERVER['HTTP_OC_CHUNKED'])) {
[$path, $name] = \Sabre\Uri\split($filePath);
$info = \OC_FileChunking::decodeName($name);
if (!empty($info)) {
$filePath = $path . '/' . $info['name'];
}
}
// we get the node for the given $filePath here because in case of afterCreateFile $node is the parent folder
if (!$this->server->tree->nodeExists($filePath)) {
return;

@ -61,7 +61,7 @@ class LockPlugin extends ServerPlugin {
public function getLock(RequestInterface $request) {
// we can't listen on 'beforeMethod:PUT' due to order of operations with setting up the tree
// so instead we limit ourselves to the PUT method manually
if ($request->getMethod() !== 'PUT') {
if ($request->getMethod() !== 'PUT' || isset($_SERVER['HTTP_OC_CHUNKED'])) {
return;
}
try {
@ -84,7 +84,7 @@ class LockPlugin extends ServerPlugin {
if ($this->isLocked === false) {
return;
}
if ($request->getMethod() !== 'PUT') {
if ($request->getMethod() !== 'PUT' || isset($_SERVER['HTTP_OC_CHUNKED'])) {
return;
}
try {

@ -38,7 +38,6 @@ namespace OCA\DAV\Connector\Sabre;
use OC\Files\Mount\MoveableMount;
use OC\Files\Node\File;
use OC\Files\Node\Folder;
use OC\Files\Node\LazyFolder;
use OC\Files\View;
use OCA\DAV\Connector\Sabre\Exception\InvalidPath;
use OCP\Files\DavUtil;
@ -89,7 +88,7 @@ abstract class Node implements \Sabre\DAV\INode {
} else {
$this->shareManager = \OC::$server->getShareManager();
}
if ($info instanceof Folder || $info instanceof File || $info instanceof LazyFolder) {
if ($info instanceof Folder || $info instanceof File) {
$this->node = $info;
} else {
$root = \OC::$server->get(IRootFolder::class);

@ -67,6 +67,35 @@ class ObjectTree extends CachingTree {
$this->mountManager = $mountManager;
}
/**
* If the given path is a chunked file name, converts it
* to the real file name. Only applies if the OC-CHUNKED header
* is present.
*
* @param string $path chunk file path to convert
*
* @return string path to real file
*/
private function resolveChunkFile($path) {
if (isset($_SERVER['HTTP_OC_CHUNKED'])) {
// resolve to real file name to find the proper node
[$dir, $name] = \Sabre\Uri\split($path);
if ($dir === '/' || $dir === '.') {
$dir = '';
}
$info = \OC_FileChunking::decodeName($name);
// only replace path if it was really the chunked file
if (isset($info['transferid'])) {
// getNodePath is called for multiple nodes within a chunk
// upload call
$path = $dir . '/' . $info['name'];
$path = ltrim($path, '/');
}
}
return $path;
}
/**
* Returns the INode object for the requested path
*
@ -118,6 +147,9 @@ class ObjectTree extends CachingTree {
$info = null;
}
} else {
// resolve chunk file name to real name, if applicable
$path = $this->resolveChunkFile($path);
// read from cache
try {
$info = $this->fileView->getFileInfo($path);
@ -127,6 +159,12 @@ class ObjectTree extends CachingTree {
}
} catch (StorageNotAvailableException $e) {
throw new \Sabre\DAV\Exception\ServiceUnavailable('Storage is temporarily not available', 0, $e);
} catch (StorageInvalidException $e) {
throw new \Sabre\DAV\Exception\NotFound('Storage ' . $path . ' is invalid');
} catch (LockedException $e) {
throw new \Sabre\DAV\Exception\Locked();
} catch (ForbiddenException $e) {
throw new \Sabre\DAV\Exception\Forbidden();
}
}

@ -193,14 +193,31 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin {
$parentPath = '';
}
$req = $this->server->httpRequest;
if ($req->getHeader('OC-Chunked')) {
$info = \OC_FileChunking::decodeName($newName);
$chunkHandler = $this->getFileChunking($info);
// subtract the already uploaded size to see whether
// there is still enough space for the remaining chunks
$length -= $chunkHandler->getCurrentSize();
// use target file name for free space check in case of shared files
$path = rtrim($parentPath, '/') . '/' . $info['name'];
}
$freeSpace = $this->getFreeSpace($path);
if ($freeSpace >= 0 && $length > $freeSpace) {
if (isset($chunkHandler)) {
$chunkHandler->cleanup();
}
throw new InsufficientStorage("Insufficient space in $path, $length required, $freeSpace available");
}
}
return true;
}
public function getFileChunking($info) {
// FIXME: need a factory for better mocking support
return new \OC_FileChunking($info);
}
public function getLength() {
$req = $this->server->httpRequest;
$length = $req->getHeader('X-Expected-Entity-Length');

@ -73,6 +73,7 @@ class FileTest extends TestCase {
protected function setUp(): void {
parent::setUp();
unset($_SERVER['HTTP_OC_CHUNKED']);
unset($_SERVER['CONTENT_LENGTH']);
unset($_SERVER['REQUEST_METHOD']);
@ -90,6 +91,7 @@ class FileTest extends TestCase {
protected function tearDown(): void {
$userManager = \OC::$server->getUserManager();
$userManager->get($this->user)->delete();
unset($_SERVER['HTTP_OC_CHUNKED']);
parent::tearDown();
}
@ -232,6 +234,81 @@ class FileTest extends TestCase {
$this->assertEmpty($this->listPartFiles($view, ''), 'No stray part files');
}
/**
* Test putting a file using chunking
*
* @dataProvider fopenFailuresProvider
*/
public function testChunkedPutFails($thrownException, $expectedException, $checkPreviousClass = false): void {
// setup
$storage = $this->getMockBuilder(Local::class)
->setMethods(['fopen'])
->setConstructorArgs([['datadir' => \OC::$server->getTempManager()->getTemporaryFolder()]])
->getMock();
\OC\Files\Filesystem::mount($storage, [], $this->user . '/');
$view = $this->getMockBuilder(View::class)
->setMethods(['getRelativePath', 'resolvePath'])
->getMock();
$view->expects($this->atLeastOnce())
->method('resolvePath')
->willReturnCallback(
function ($path) use ($storage) {
return [$storage, $path];
}
);
if ($thrownException !== null) {
$storage->expects($this->once())
->method('fopen')
->will($this->throwException($thrownException));
} else {
$storage->expects($this->once())
->method('fopen')
->willReturn(false);
}
$view->expects($this->any())
->method('getRelativePath')
->willReturnArgument(0);
$_SERVER['HTTP_OC_CHUNKED'] = true;
$info = new \OC\Files\FileInfo('/test.txt-chunking-12345-2-0', $this->getMockStorage(), null, [
'permissions' => \OCP\Constants::PERMISSION_ALL,
'type' => FileInfo::TYPE_FOLDER,
], null);
$file = new \OCA\DAV\Connector\Sabre\File($view, $info);
// put first chunk
$file->acquireLock(ILockingProvider::LOCK_SHARED);
$this->assertNull($file->put('test data one'));
$file->releaseLock(ILockingProvider::LOCK_SHARED);
$info = new \OC\Files\FileInfo('/test.txt-chunking-12345-2-1', $this->getMockStorage(), null, [
'permissions' => \OCP\Constants::PERMISSION_ALL,
'type' => FileInfo::TYPE_FOLDER,
], null);
$file = new \OCA\DAV\Connector\Sabre\File($view, $info);
// action
$caughtException = null;
try {
// last chunk
$file->acquireLock(ILockingProvider::LOCK_SHARED);
$file->put('test data two');
$file->releaseLock(ILockingProvider::LOCK_SHARED);
} catch (\Exception $e) {
$caughtException = $e;
}
$this->assertInstanceOf($expectedException, $caughtException);
if ($checkPreviousClass) {
$this->assertInstanceOf(get_class($thrownException), $caughtException->getPrevious());
}
$this->assertEmpty($this->listPartFiles($view, ''), 'No stray part files');
}
/**
* Simulate putting a file to the given path.
*
@ -360,6 +437,41 @@ class FileTest extends TestCase {
}
}
/**
* Test putting a file with string Mtime using chunking
* @dataProvider legalMtimeProvider
*/
public function testChunkedPutLegalMtime($requestMtime, $resultMtime): void {
$request = new Request([
'server' => [
'HTTP_X_OC_MTIME' => $requestMtime,
]
], $this->requestId, $this->config, null);
$_SERVER['HTTP_OC_CHUNKED'] = true;
$file = 'foo.txt';
if ($resultMtime === null) {
$this->expectException(\Sabre\DAV\Exception::class);
}
$this->doPut($file.'-chunking-12345-2-0', null, $request);
$this->doPut($file.'-chunking-12345-2-1', null, $request);
if ($resultMtime !== null) {
$this->assertEquals($resultMtime, $this->getFileInfos($file)['mtime']);
}
}
/**
* Test putting a file using chunking
*/
public function testChunkedPut(): void {
$_SERVER['HTTP_OC_CHUNKED'] = true;
$this->assertNull($this->doPut('/test.txt-chunking-12345-2-0'));
$this->assertNotEmpty($this->doPut('/test.txt-chunking-12345-2-1'));
}
/**
* Test that putting a file triggers create hooks
*/
@ -462,6 +574,75 @@ class FileTest extends TestCase {
);
}
/**
* Test that putting a file with chunks triggers create hooks
*/
public function testPutChunkedFileTriggersHooks(): void {
HookHelper::setUpHooks();
$_SERVER['HTTP_OC_CHUNKED'] = true;
$this->assertNull($this->doPut('/foo.txt-chunking-12345-2-0'));
$this->assertNotEmpty($this->doPut('/foo.txt-chunking-12345-2-1'));
$this->assertCount(4, HookHelper::$hookCalls);
$this->assertHookCall(
HookHelper::$hookCalls[0],
Filesystem::signal_create,
'/foo.txt'
);
$this->assertHookCall(
HookHelper::$hookCalls[1],
Filesystem::signal_write,
'/foo.txt'
);
$this->assertHookCall(
HookHelper::$hookCalls[2],
Filesystem::signal_post_create,
'/foo.txt'
);
$this->assertHookCall(
HookHelper::$hookCalls[3],
Filesystem::signal_post_write,
'/foo.txt'
);
}
/**
* Test that putting a chunked file triggers update hooks
*/
public function testPutOverwriteChunkedFileTriggersHooks(): void {
$view = \OC\Files\Filesystem::getView();
$view->file_put_contents('/foo.txt', 'some content that will be replaced');
HookHelper::setUpHooks();
$_SERVER['HTTP_OC_CHUNKED'] = true;
$this->assertNull($this->doPut('/foo.txt-chunking-12345-2-0'));
$this->assertNotEmpty($this->doPut('/foo.txt-chunking-12345-2-1'));
$this->assertCount(4, HookHelper::$hookCalls);
$this->assertHookCall(
HookHelper::$hookCalls[0],
Filesystem::signal_update,
'/foo.txt'
);
$this->assertHookCall(
HookHelper::$hookCalls[1],
Filesystem::signal_write,
'/foo.txt'
);
$this->assertHookCall(
HookHelper::$hookCalls[2],
Filesystem::signal_post_update,
'/foo.txt'
);
$this->assertHookCall(
HookHelper::$hookCalls[3],
Filesystem::signal_post_write,
'/foo.txt'
);
}
public static function cancellingHook($params): void {
self::$hookCalls[] = [
'signal' => Filesystem::signal_post_create,
@ -574,6 +755,46 @@ class FileTest extends TestCase {
$this->assertEmpty($this->listPartFiles($view, ''), 'No stray part files');
}
/**
* Test exception during final rename in chunk upload mode
*/
public function testChunkedPutFailsFinalRename(): void {
$view = new \OC\Files\View('/' . $this->user . '/files');
// simulate situation where the target file is locked
$view->lockFile('/test.txt', ILockingProvider::LOCK_EXCLUSIVE);
$_SERVER['HTTP_OC_CHUNKED'] = true;
$info = new \OC\Files\FileInfo('/' . $this->user . '/files/test.txt-chunking-12345-2-0', $this->getMockStorage(), null, [
'permissions' => \OCP\Constants::PERMISSION_ALL,
'type' => FileInfo::TYPE_FOLDER,
], null);
$file = new \OCA\DAV\Connector\Sabre\File($view, $info);
$file->acquireLock(ILockingProvider::LOCK_SHARED);
$this->assertNull($file->put('test data one'));
$file->releaseLock(ILockingProvider::LOCK_SHARED);
$info = new \OC\Files\FileInfo('/' . $this->user . '/files/test.txt-chunking-12345-2-1', $this->getMockStorage(), null, [
'permissions' => \OCP\Constants::PERMISSION_ALL,
'type' => FileInfo::TYPE_FOLDER,
], null);
$file = new \OCA\DAV\Connector\Sabre\File($view, $info);
// action
$thrown = false;
try {
$file->acquireLock(ILockingProvider::LOCK_SHARED);
$file->put($this->getStream('test data'));
$file->releaseLock(ILockingProvider::LOCK_SHARED);
} catch (\OCA\DAV\Connector\Sabre\Exception\FileLocked $e) {
$thrown = true;
}
$this->assertTrue($thrown);
$this->assertEmpty($this->listPartFiles($view, ''), 'No stray part files');
}
/**
* Test put file with invalid chars
*/

@ -148,8 +148,13 @@ class ObjectTreeTest extends \Test\TestCase {
$inputFileName,
$fileInfoQueryPath,
$outputFileName,
$type
$type,
$enableChunkingHeader
): void {
if ($enableChunkingHeader) {
$_SERVER['HTTP_OC_CHUNKED'] = true;
}
$rootNode = $this->getMockBuilder(Directory::class)
->disableOriginalConstructor()
->getMock();
@ -186,6 +191,8 @@ class ObjectTreeTest extends \Test\TestCase {
} else {
$this->assertTrue($node instanceof \OCA\DAV\Connector\Sabre\Directory);
}
unset($_SERVER['HTTP_OC_CHUNKED']);
}
public function nodeForPathProvider() {
@ -195,67 +202,94 @@ class ObjectTreeTest extends \Test\TestCase {
'regularfile.txt',
'regularfile.txt',
'regularfile.txt',
'file'
'file',
false
],
// regular directory
[
'regulardir',
'regulardir',
'regulardir',
'dir'
'dir',
false
],
// regular file with chunking
[
'regularfile.txt',
'regularfile.txt',
'regularfile.txt',
'file'
'file',
true
],
// regular directory with chunking
[
'regulardir',
'regulardir',
'regulardir',
'dir'
'dir',
true
],
// file with chunky file name
[
'regularfile.txt-chunking-123566789-10-1',
'regularfile.txt',
'regularfile.txt',
'file',
true
],
// regular file in subdir
[
'subdir/regularfile.txt',
'subdir/regularfile.txt',
'regularfile.txt',
'file'
'file',
false
],
// regular directory in subdir
[
'subdir/regulardir',
'subdir/regulardir',
'regulardir',
'dir'
'dir',
false
],
// file with chunky file name in subdir
[
'subdir/regularfile.txt-chunking-123566789-10-1',
'subdir/regularfile.txt',
'regularfile.txt',
'file',
true
],
];
}
public function testGetNodeForPathInvalidPath(): void {
$this->expectException(\OCA\DAV\Connector\Sabre\Exception\InvalidPath::class);
$path = '/foo\bar';
$storage = new Temporary([]);
$rootNode = $this->getMockBuilder(Directory::class)
->disableOriginalConstructor()
->getMock();
$mountManager = $this->createMock(IMountManager::class);
$view = $this->getMockBuilder(View::class)
->setMethods(['resolvePath'])
->getMock();
$this->expectException(\OCA\DAV\Connector\Sabre\Exception\InvalidPath::class);
$view->expects($this->once())
->method('resolvePath')
->willReturnCallback(function ($path) use ($storage) {
return [$storage, ltrim($path, '/')];
});
$rootNode = $this->getMockBuilder(Directory::class)
->disableOriginalConstructor()
->getMock();
$mountManager = $this->createMock(IMountManager::class);
$tree = new \OCA\DAV\Connector\Sabre\ObjectTree();
$tree->init($rootNode, $view, $mountManager);
$tree->getNodeForPath($path);
}

@ -52,7 +52,10 @@ class QuotaPluginTest extends TestCase {
private function init($quota, $checkedPath = ''): void {
$view = $this->buildFileViewMock($quota, $checkedPath);
$this->server = new \Sabre\DAV\Server();
$this->plugin = new QuotaPlugin($view);
$this->plugin = $this->getMockBuilder(QuotaPlugin::class)
->setConstructorArgs([$view])
->setMethods(['getFileChunking'])
->getMock();
$this->plugin->initialize($this->server);
}
@ -61,6 +64,8 @@ class QuotaPluginTest extends TestCase {
*/
public function testLength($expected, $headers): void {
$this->init(0);
$this->plugin->expects($this->never())
->method('getFileChunking');
$this->server->httpRequest = new \Sabre\HTTP\Request('POST', 'dummy.file', $headers);
$length = $this->plugin->getLength();
$this->assertEquals($expected, $length);
@ -71,6 +76,8 @@ class QuotaPluginTest extends TestCase {
*/
public function testCheckQuota($quota, $headers): void {
$this->init($quota);
$this->plugin->expects($this->never())
->method('getFileChunking');
$this->server->httpRequest = new \Sabre\HTTP\Request('POST', 'dummy.file', $headers);
$result = $this->plugin->checkQuota('');
@ -84,6 +91,8 @@ class QuotaPluginTest extends TestCase {
$this->expectException(\Sabre\DAV\Exception\InsufficientStorage::class);
$this->init($quota);
$this->plugin->expects($this->never())
->method('getFileChunking');
$this->server->httpRequest = new \Sabre\HTTP\Request('POST', 'dummy.file', $headers);
$this->plugin->checkQuota('');
@ -94,6 +103,8 @@ class QuotaPluginTest extends TestCase {
*/
public function testCheckQuotaOnPath($quota, $headers): void {
$this->init($quota, 'sub/test.txt');
$this->plugin->expects($this->never())
->method('getFileChunking');
$this->server->httpRequest = new \Sabre\HTTP\Request('POST', 'dummy.file', $headers);
$result = $this->plugin->checkQuota('/sub/test.txt');
@ -143,6 +154,84 @@ class QuotaPluginTest extends TestCase {
];
}
public function quotaChunkedOkProvider() {
return [
[1024, 0, ['X-EXPECTED-ENTITY-LENGTH' => '1024']],
[1024, 0, ['CONTENT-LENGTH' => '512']],
[1024, 0, ['OC-TOTAL-LENGTH' => '1024', 'CONTENT-LENGTH' => '512']],
// with existing chunks (allowed size = total length - chunk total size)
[400, 128, ['X-EXPECTED-ENTITY-LENGTH' => '512']],
[400, 128, ['CONTENT-LENGTH' => '512']],
[400, 128, ['OC-TOTAL-LENGTH' => '512', 'CONTENT-LENGTH' => '500']],
// \OCP\Files\FileInfo::SPACE-UNKNOWN = -2
[-2, 0, ['X-EXPECTED-ENTITY-LENGTH' => '1024']],
[-2, 0, ['CONTENT-LENGTH' => '512']],
[-2, 0, ['OC-TOTAL-LENGTH' => '1024', 'CONTENT-LENGTH' => '512']],
[-2, 128, ['X-EXPECTED-ENTITY-LENGTH' => '1024']],
[-2, 128, ['CONTENT-LENGTH' => '512']],
[-2, 128, ['OC-TOTAL-LENGTH' => '1024', 'CONTENT-LENGTH' => '512']],
];
}
/**
* @dataProvider quotaChunkedOkProvider
*/
public function testCheckQuotaChunkedOk($quota, $chunkTotalSize, $headers): void {
$this->init($quota, 'sub/test.txt');
$mockChunking = $this->getMockBuilder(\OC_FileChunking::class)
->disableOriginalConstructor()
->getMock();
$mockChunking->expects($this->once())
->method('getCurrentSize')
->willReturn($chunkTotalSize);
$this->plugin->expects($this->once())
->method('getFileChunking')
->willReturn($mockChunking);
$headers['OC-CHUNKED'] = 1;
$this->server->httpRequest = new \Sabre\HTTP\Request('POST', 'dummy.file', $headers);
$result = $this->plugin->checkQuota('/sub/test.txt-chunking-12345-3-1');
$this->assertTrue($result);
}
public function quotaChunkedFailProvider() {
return [
[400, 0, ['X-EXPECTED-ENTITY-LENGTH' => '1024']],
[400, 0, ['CONTENT-LENGTH' => '512']],
[400, 0, ['OC-TOTAL-LENGTH' => '1024', 'CONTENT-LENGTH' => '512']],
// with existing chunks (allowed size = total length - chunk total size)
[380, 128, ['X-EXPECTED-ENTITY-LENGTH' => '512']],
[380, 128, ['CONTENT-LENGTH' => '512']],
[380, 128, ['OC-TOTAL-LENGTH' => '512', 'CONTENT-LENGTH' => '500']],
];
}
/**
* @dataProvider quotaChunkedFailProvider
*/
public function testCheckQuotaChunkedFail($quota, $chunkTotalSize, $headers): void {
$this->expectException(\Sabre\DAV\Exception\InsufficientStorage::class);
$this->init($quota, 'sub/test.txt');
$mockChunking = $this->getMockBuilder(\OC_FileChunking::class)
->disableOriginalConstructor()
->getMock();
$mockChunking->expects($this->once())
->method('getCurrentSize')
->willReturn($chunkTotalSize);
$this->plugin->expects($this->once())
->method('getFileChunking')
->willReturn($mockChunking);
$headers['OC-CHUNKED'] = 1;
$this->server->httpRequest = new \Sabre\HTTP\Request('POST', 'dummy.file', $headers);
$this->plugin->checkQuota('/sub/test.txt-chunking-12345-3-1');
}
private function buildFileViewMock($quota, $checkedPath) {
// mock filesysten
$view = $this->getMockBuilder(View::class)

@ -57,6 +57,8 @@ abstract class RequestTestCase extends TestCase {
protected function setUp(): void {
parent::setUp();
unset($_SERVER['HTTP_OC_CHUNKED']);
$this->serverFactory = new ServerFactory(
\OC::$server->getConfig(),
\OC::$server->get(LoggerInterface::class),

@ -92,4 +92,114 @@ class UploadTest extends RequestTestCase {
$this->assertEquals(Http::STATUS_LOCKED, $result->getStatus());
}
public function testChunkedUpload(): void {
$user = $this->getUniqueID();
$view = $this->setupUser($user, 'pass');
$this->assertFalse($view->file_exists('foo.txt'));
$response = $this->request($view, $user, 'pass', 'PUT', '/foo.txt-chunking-123-2-0', 'asd', ['OC-Chunked' => '1']);
$this->assertEquals(201, $response->getStatus());
$this->assertFalse($view->file_exists('foo.txt'));
$response = $this->request($view, $user, 'pass', 'PUT', '/foo.txt-chunking-123-2-1', 'bar', ['OC-Chunked' => '1']);
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
$this->assertTrue($view->file_exists('foo.txt'));
$this->assertEquals('asdbar', $view->file_get_contents('foo.txt'));
$info = $view->getFileInfo('foo.txt');
$this->assertInstanceOf('\OC\Files\FileInfo', $info);
$this->assertEquals(6, $info->getSize());
}
public function testChunkedUploadOverWrite(): void {
$user = $this->getUniqueID();
$view = $this->setupUser($user, 'pass');
$view->file_put_contents('foo.txt', 'bar');
$response = $this->request($view, $user, 'pass', 'PUT', '/foo.txt-chunking-123-2-0', 'asd', ['OC-Chunked' => '1']);
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
$this->assertEquals('bar', $view->file_get_contents('foo.txt'));
$response = $this->request($view, $user, 'pass', 'PUT', '/foo.txt-chunking-123-2-1', 'bar', ['OC-Chunked' => '1']);
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
$this->assertEquals('asdbar', $view->file_get_contents('foo.txt'));
$info = $view->getFileInfo('foo.txt');
$this->assertInstanceOf('\OC\Files\FileInfo', $info);
$this->assertEquals(6, $info->getSize());
}
public function testChunkedUploadOutOfOrder(): void {
$user = $this->getUniqueID();
$view = $this->setupUser($user, 'pass');
$this->assertFalse($view->file_exists('foo.txt'));
$response = $this->request($view, $user, 'pass', 'PUT', '/foo.txt-chunking-123-2-1', 'bar', ['OC-Chunked' => '1']);
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
$this->assertFalse($view->file_exists('foo.txt'));
$response = $this->request($view, $user, 'pass', 'PUT', '/foo.txt-chunking-123-2-0', 'asd', ['OC-Chunked' => '1']);
$this->assertEquals(201, $response->getStatus());
$this->assertTrue($view->file_exists('foo.txt'));
$this->assertEquals('asdbar', $view->file_get_contents('foo.txt'));
$info = $view->getFileInfo('foo.txt');
$this->assertInstanceOf('\OC\Files\FileInfo', $info);
$this->assertEquals(6, $info->getSize());
}
public function testChunkedUploadOutOfOrderReadLocked(): void {
$user = $this->getUniqueID();
$view = $this->setupUser($user, 'pass');
$this->assertFalse($view->file_exists('foo.txt'));
$view->lockFile('/foo.txt', ILockingProvider::LOCK_SHARED);
try {
$response = $this->request($view, $user, 'pass', 'PUT', '/foo.txt-chunking-123-2-1', 'bar', ['OC-Chunked' => '1']);
} catch (\OCA\DAV\Connector\Sabre\Exception\FileLocked $e) {
$this->fail('Didn\'t expect locked error for the first chunk on read lock');
return;
}
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
$this->assertFalse($view->file_exists('foo.txt'));
// last chunk should trigger the locked error since it tries to assemble
$result = $this->request($view, $user, 'pass', 'PUT', '/foo.txt-chunking-123-2-0', 'asd', ['OC-Chunked' => '1']);
$this->assertEquals(Http::STATUS_LOCKED, $result->getStatus());
}
public function testChunkedUploadOutOfOrderWriteLocked(): void {
$user = $this->getUniqueID();
$view = $this->setupUser($user, 'pass');
$this->assertFalse($view->file_exists('foo.txt'));
$view->lockFile('/foo.txt', ILockingProvider::LOCK_EXCLUSIVE);
try {
$response = $this->request($view, $user, 'pass', 'PUT', '/foo.txt-chunking-123-2-1', 'bar', ['OC-Chunked' => '1']);
} catch (\OCA\DAV\Connector\Sabre\Exception\FileLocked $e) {
$this->fail('Didn\'t expect locked error for the first chunk on write lock'); // maybe forbid this in the future for write locks only?
return;
}
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
$this->assertFalse($view->file_exists('foo.txt'));
// last chunk should trigger the locked error since it tries to assemble
$result = $this->request($view, $user, 'pass', 'PUT', '/foo.txt-chunking-123-2-0', 'asd', ['OC-Chunked' => '1']);
$this->assertEquals(Http::STATUS_LOCKED, $result->getStatus());
}
}

@ -5,7 +5,7 @@
<name>Files</name>
<summary>File Management</summary>
<description>File Management</description>
<version>1.21.2</version>
<version>1.21.1</version>
<licence>agpl</licence>
<author>Robin Appelman</author>
<author>Vincent Petry</author>
@ -22,12 +22,11 @@
</dependencies>
<background-jobs>
<job>OCA\Files\BackgroundJob\CleanupDirectEditingTokens</job>
<job>OCA\Files\BackgroundJob\ScanFiles</job>
<job>OCA\Files\BackgroundJob\DeleteOrphanedItems</job>
<job>OCA\Files\BackgroundJob\CleanupFileLocks</job>
<job>OCA\Files\BackgroundJob\CleanupDirectEditingTokens</job>
<job>OCA\Files\BackgroundJob\DeleteExpiredOpenLocalEditor</job>
<job>OCA\Files\BackgroundJob\DeleteOrphanedItems</job>
<job>OCA\Files\BackgroundJob\FileChunkCleanupJob</job>
<job>OCA\Files\BackgroundJob\ScanFiles</job>
</background-jobs>
<commands>

@ -429,8 +429,7 @@ class ClassLoader
public function loadClass($class)
{
if ($file = $this->findFile($class)) {
$includeFile = self::$includeFile;
$includeFile($file);
(self::$includeFile)($file);
return true;
}
@ -561,10 +560,7 @@ class ClassLoader
return false;
}
/**
* @return void
*/
private static function initializeIncludeClosure()
private static function initializeIncludeClosure(): void
{
if (self::$includeFile !== null) {
return;
@ -578,8 +574,8 @@ class ClassLoader
* @param string $file
* @return void
*/
self::$includeFile = \Closure::bind(static function($file) {
self::$includeFile = static function($file) {
include $file;
}, null, null);
};
}
}

@ -22,7 +22,6 @@ return array(
'OCA\\Files\\BackgroundJob\\CleanupFileLocks' => $baseDir . '/../lib/BackgroundJob/CleanupFileLocks.php',
'OCA\\Files\\BackgroundJob\\DeleteExpiredOpenLocalEditor' => $baseDir . '/../lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php',
'OCA\\Files\\BackgroundJob\\DeleteOrphanedItems' => $baseDir . '/../lib/BackgroundJob/DeleteOrphanedItems.php',
'OCA\\Files\\BackgroundJob\\FileChunkCleanupJob' => $baseDir . '/../lib/BackgroundJob/FileChunkCleanupJob.php',
'OCA\\Files\\BackgroundJob\\ScanFiles' => $baseDir . '/../lib/BackgroundJob/ScanFiles.php',
'OCA\\Files\\BackgroundJob\\TransferOwnership' => $baseDir . '/../lib/BackgroundJob/TransferOwnership.php',
'OCA\\Files\\Capabilities' => $baseDir . '/../lib/Capabilities.php',

@ -37,7 +37,6 @@ class ComposerStaticInitFiles
'OCA\\Files\\BackgroundJob\\CleanupFileLocks' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupFileLocks.php',
'OCA\\Files\\BackgroundJob\\DeleteExpiredOpenLocalEditor' => __DIR__ . '/..' . '/../lib/BackgroundJob/DeleteExpiredOpenLocalEditor.php',
'OCA\\Files\\BackgroundJob\\DeleteOrphanedItems' => __DIR__ . '/..' . '/../lib/BackgroundJob/DeleteOrphanedItems.php',
'OCA\\Files\\BackgroundJob\\FileChunkCleanupJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/FileChunkCleanupJob.php',
'OCA\\Files\\BackgroundJob\\ScanFiles' => __DIR__ . '/..' . '/../lib/BackgroundJob/ScanFiles.php',
'OCA\\Files\\BackgroundJob\\TransferOwnership' => __DIR__ . '/..' . '/../lib/BackgroundJob/TransferOwnership.php',
'OCA\\Files\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',

@ -3,7 +3,7 @@
'name' => '__root__',
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => '3e452cfe8d80995d1657c617f887a9ee422e6ab1',
'reference' => 'd51429a47232bbf46a2be832ecfa711f102da802',
'type' => 'library',
'install_path' => __DIR__ . '/../',
'aliases' => array(),
@ -13,7 +13,7 @@
'__root__' => array(
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => '3e452cfe8d80995d1657c617f887a9ee422e6ab1',
'reference' => 'd51429a47232bbf46a2be832ecfa711f102da802',
'type' => 'library',
'install_path' => __DIR__ . '/../',
'aliases' => array(),

@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2023 Anna Larch <anna.larch@gmx.net>
*
* @author Anna Larch <anna.larch@gmx.net>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Files\BackgroundJob;
use OC\Cache\File;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\Job;
use OCP\BackgroundJob\TimedJob;
use OCP\Files\IRootFolder;
use OCP\IUser;
use OCP\IUserManager;
use Psr\Log\LoggerInterface;
class FileChunkCleanupJob extends TimedJob {
private IUserManager $userManager;
private IRootFolder $rootFolder;
private LoggerInterface $logger;
public function __construct(IUserManager $userManager, IRootFolder $rootFolder, LoggerInterface $logger, ITimeFactory $timeFactory) {
parent::__construct($timeFactory);
$this->setInterval(3600*24);
$this->setTimeSensitivity(Job::TIME_INSENSITIVE);
$this->userManager = $userManager;
$this->rootFolder = $rootFolder;
$this->logger = $logger;
}
/**
* This job cleans up all backups except the latest 3 from the updaters backup directory
*/
public function run($argument): void {
$this->userManager->callForSeenUsers(function (IUser $user): void {
$this->logger->debug('Running chunk cleanup job for user '. $user->getUID());
$fileCache = new File();
$fileCache->setUpStorage($user->getUID());
$fileCache->gc();
$this->logger->debug('Finished running chunk cleanup job for user '. $user->getUID());
});
}
}

@ -73,7 +73,6 @@ class ApiTest extends TestCase {
\OC::$server->getConfig()->setAppValue('core', 'shareapi_exclude_groups', 'no');
\OC::$server->getConfig()->setAppValue('core', 'shareapi_expire_after_n_days', '7');
\OC::$server->getConfig()->setAppValue('core', 'shareapi_enforce_links_password', 'no');
$this->folder = self::TEST_FOLDER_NAME;
$this->subfolder = '/subfolder_share_api_test';
@ -81,11 +80,6 @@ class ApiTest extends TestCase {
$this->filename = '/share-api-test.txt';
// Initialize view again as we delete all filecache/mount entries in tearDown
// Otherwise those tests fail on object storage as the filecache is missing the user home
$this->view = new \OC\Files\View('/' . self::TEST_FILES_SHARING_API_USER1 . '/files');
$this->view2 = new \OC\Files\View('/' . self::TEST_FILES_SHARING_API_USER2 . '/files');
// save file with content
$this->view->file_put_contents($this->filename, $this->data);
$this->view->mkdir($this->folder);

@ -33,7 +33,6 @@
namespace OCA\Files_Sharing\Tests;
use OC\Files\Filesystem;
use OC\Files\View;
use OCA\Files_Sharing\AppInfo\Application;
use OCA\Files_Sharing\External\MountProvider as ExternalMountProvider;
use OCA\Files_Sharing\MountProvider;
@ -212,12 +211,7 @@ abstract class TestCase extends \Test\TestCase {
\OC::$server->getUserSession()->setUser(null);
\OC\Files\Filesystem::tearDown();
\OC::$server->getUserSession()->login($user, $password);
// We need to get the directory listing to trigger the lazy user folder
// to create the files directory. Since the filecache might get cleared
// in the cache, any follow up test case may fail as with object storage
// the filecache represents the file structure
Filesystem::initMountPoints($user);
\OC::$server->getUserFolder($user)->getDirectoryListing();
\OC::$server->getUserFolder($user);
\OC_Util::setupFS($user);
}

@ -231,4 +231,31 @@ class ChecksumsContext implements \Behat\Behat\Context\Context {
throw new \Exception("Expected no checksum header but got ".$this->response->getHeader('OC-Checksum')[0]);
}
}
/**
* @Given user :user uploads chunk file :num of :total with :data to :destination with checksum :checksum
* @param string $user
* @param int $num
* @param int $total
* @param string $data
* @param string $destination
* @param string $checksum
*/
public function userUploadsChunkFileOfWithToWithChecksum($user, $num, $total, $data, $destination, $checksum) {
$num -= 1;
$this->response = $this->client->put(
$this->baseUrl . '/remote.php/webdav' . $destination . '-chunking-42-'.$total.'-'.$num,
[
'auth' => [
$user,
$this->getPasswordForUser($user)
],
'body' => $data,
'headers' => [
'OC-Checksum' => $checksum,
'OC-Chunked' => '1',
]
]
);
}
}

@ -58,3 +58,19 @@ Feature: checksums
When user "user0" uploads file "data/textfile.txt" to "/myChecksumFile.txt"
And user "user0" downloads the file "/myChecksumFile.txt"
Then The OC-Checksum header should not be there
Scenario: Uploading a chunked file with checksum should return the checksum in the propfind
Given user "user0" exists
And user "user0" uploads chunk file "1" of "3" with "AAAAA" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88"
And user "user0" uploads chunk file "2" of "3" with "BBBBB" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88"
And user "user0" uploads chunk file "3" of "3" with "CCCCC" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88"
When user "user0" request the checksum of "/myChecksumFile.txt" via propfind
Then The webdav checksum should match "MD5:e892fdd61a74bc89cd05673cc2e22f88"
Scenario: Uploading a chunked file with checksum should return the checksum in the download header
Given user "user0" exists
And user "user0" uploads chunk file "1" of "3" with "AAAAA" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88"
And user "user0" uploads chunk file "2" of "3" with "BBBBB" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88"
And user "user0" uploads chunk file "3" of "3" with "CCCCC" to "/myChecksumFile.txt" with checksum "MD5:e892fdd61a74bc89cd05673cc2e22f88"
When user "user0" downloads the file "/myChecksumFile.txt"
Then The header checksum should match "MD5:e892fdd61a74bc89cd05673cc2e22f88"

@ -277,6 +277,33 @@ Feature: webdav-related
When Sending a "PROPFIND" to "/remote.php/webdav/welcome.txt" with requesttoken
Then the HTTP status code should be "207"
Scenario: Upload chunked file asc
Given user "user0" exists
And user "user0" uploads chunk file "1" of "3" with "AAAAA" to "/myChunkedFile.txt"
And user "user0" uploads chunk file "2" of "3" with "BBBBB" to "/myChunkedFile.txt"
And user "user0" uploads chunk file "3" of "3" with "CCCCC" to "/myChunkedFile.txt"
When As an "user0"
And Downloading file "/myChunkedFile.txt"
Then Downloaded content should be "AAAAABBBBBCCCCC"
Scenario: Upload chunked file desc
Given user "user0" exists
And user "user0" uploads chunk file "3" of "3" with "CCCCC" to "/myChunkedFile.txt"
And user "user0" uploads chunk file "2" of "3" with "BBBBB" to "/myChunkedFile.txt"
And user "user0" uploads chunk file "1" of "3" with "AAAAA" to "/myChunkedFile.txt"
When As an "user0"
And Downloading file "/myChunkedFile.txt"
Then Downloaded content should be "AAAAABBBBBCCCCC"
Scenario: Upload chunked file random
Given user "user0" exists
And user "user0" uploads chunk file "2" of "3" with "BBBBB" to "/myChunkedFile.txt"
And user "user0" uploads chunk file "3" of "3" with "CCCCC" to "/myChunkedFile.txt"
And user "user0" uploads chunk file "1" of "3" with "AAAAA" to "/myChunkedFile.txt"
When As an "user0"
And Downloading file "/myChunkedFile.txt"
Then Downloaded content should be "AAAAABBBBBCCCCC"
Scenario: A file that is not shared does not have a share-types property
Given user "user0" exists
And user "user0" created a folder "/test"

@ -855,6 +855,23 @@ class OC {
$throttler = Server::get(\OC\Security\Bruteforce\Throttler::class);
$throttler->resetDelay($request->getRemoteAddress(), 'login', ['user' => $uid]);
}
try {
$cache = new \OC\Cache\File();
$cache->gc();
} catch (\OC\ServerNotAvailableException $e) {
// not a GC exception, pass it on
throw $e;
} catch (\OC\ForbiddenException $e) {
// filesystem blocked for this request, ignore
} catch (\Exception $e) {
// a GC exception should not prevent users from using OC,
// so log the exception
Server::get(LoggerInterface::class)->warning('Exception when running cache gc.', [
'app' => 'core',
'exception' => $e,
]);
}
});
}
}

@ -1620,6 +1620,7 @@ return array(
'OC_App' => $baseDir . '/lib/private/legacy/OC_App.php',
'OC_Defaults' => $baseDir . '/lib/private/legacy/OC_Defaults.php',
'OC_EventSource' => $baseDir . '/lib/private/legacy/OC_EventSource.php',
'OC_FileChunking' => $baseDir . '/lib/private/legacy/OC_FileChunking.php',
'OC_Files' => $baseDir . '/lib/private/legacy/OC_Files.php',
'OC_Helper' => $baseDir . '/lib/private/legacy/OC_Helper.php',
'OC_Hook' => $baseDir . '/lib/private/legacy/OC_Hook.php',

@ -1653,6 +1653,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC_App' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_App.php',
'OC_Defaults' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_Defaults.php',
'OC_EventSource' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_EventSource.php',
'OC_FileChunking' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_FileChunking.php',
'OC_Files' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_Files.php',
'OC_Helper' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_Helper.php',
'OC_Hook' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_Hook.php',

@ -35,26 +35,10 @@ use OCP\ICache;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;
/**
* @deprecated 26.0.0
*/
class File implements ICache {
/** @var View */
protected $storage;
/**
* Set the cache storage for a user
*/
public function setUpStorage(string $userId) {
Filesystem::initMountPoints($userId);
$rootView = new View();
if (!$rootView->file_exists('/' . $userId . '/cache')) {
$rootView->mkdir('/' . $userId . '/cache');
}
$this->storage = new View('/' . $userId . '/cache');
return $this->storage;
}
/**
* Returns the cache storage for the logged in user
*
@ -67,8 +51,14 @@ class File implements ICache {
return $this->storage;
}
if (\OC::$server->getUserSession()->isLoggedIn()) {
$rootView = new View();
$user = \OC::$server->getUserSession()->getUser();
return $this->setUpStorage($user->getUID());
Filesystem::initMountPoints($user->getUID());
if (!$rootView->file_exists('/' . $user->getUID() . '/cache')) {
$rootView->mkdir('/' . $user->getUID() . '/cache');
}
$this->storage = new View('/' . $user->getUID() . '/cache');
return $this->storage;
} else {
\OC::$server->get(LoggerInterface::class)->error('Can\'t get cache storage, user not logged in', ['app' => 'core']);
throw new \OC\ForbiddenException('Can\t get cache storage, user not logged in');

@ -708,7 +708,6 @@ class Server extends ServerContainer implements IServerContainer {
$this->registerService(ICache::class, function ($c) {
return new Cache\File();
});
/** @deprecated 19.0.0 */
$this->registerDeprecatedAlias('UserCache', ICache::class);

@ -0,0 +1,184 @@
<?php
/**
* @copyright Copyright (c) 2016, ownCloud, Inc.
*
* @author Bart Visscher <bartv@thisnet.nl>
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Felix Moeller <mail@felixmoeller.de>
* @author Jörn Friedrich Dreyer <jfd@butonic.de>
* @author Morris Jobke <hey@morrisjobke.de>
* @author Robin Appelman <robin@icewind.nl>
* @author Roeland Jago Douma <roeland@famdouma.nl>
* @author Thomas Müller <thomas.mueller@tmit.eu>
* @author Thomas Tanghus <thomas@tanghus.net>
* @author Vincent Petry <vincent@nextcloud.com>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
class OC_FileChunking {
protected $info;
protected $cache;
/**
* TTL of chunks
*
* @var int
*/
protected $ttl;
public static function decodeName($name) {
preg_match('/(?P<name>.*)-chunking-(?P<transferid>\d+)-(?P<chunkcount>\d+)-(?P<index>\d+)/', $name, $matches);
return $matches;
}
/**
* @param string[] $info
*/
public function __construct($info) {
$this->info = $info;
$this->ttl = \OC::$server->getConfig()->getSystemValue('cache_chunk_gc_ttl', 86400);
}
public function getPrefix() {
$name = $this->info['name'];
$transferid = $this->info['transferid'];
return $name.'-chunking-'.$transferid.'-';
}
protected function getCache() {
if (!isset($this->cache)) {
$this->cache = new \OC\Cache\File();
}
return $this->cache;
}
/**
* Stores the given $data under the given $key - the number of stored bytes is returned
*
* @param string $index
* @param resource $data
* @return int
*/
public function store($index, $data) {
$cache = $this->getCache();
$name = $this->getPrefix().$index;
$cache->set($name, $data, $this->ttl);
return $cache->size($name);
}
public function isComplete() {
$prefix = $this->getPrefix();
$cache = $this->getCache();
$chunkcount = (int)$this->info['chunkcount'];
for ($i = ($chunkcount - 1); $i >= 0; $i--) {
if (!$cache->hasKey($prefix.$i)) {
return false;
}
}
return true;
}
/**
* Assembles the chunks into the file specified by the path.
* Chunks are deleted afterwards.
*
* @param resource $f target path
*
* @return integer assembled file size
*
* @throws \OC\InsufficientStorageException when file could not be fully
* assembled due to lack of free space
*/
public function assemble($f) {
$cache = $this->getCache();
$prefix = $this->getPrefix();
$count = 0;
for ($i = 0; $i < $this->info['chunkcount']; $i++) {
$chunk = $cache->get($prefix.$i);
// remove after reading to directly save space
$cache->remove($prefix.$i);
$count += fwrite($f, $chunk);
// let php release the memory to work around memory exhausted error with php 5.6
$chunk = null;
}
return $count;
}
/**
* Returns the size of the chunks already present
* @return integer size in bytes
*/
public function getCurrentSize() {
$cache = $this->getCache();
$prefix = $this->getPrefix();
$total = 0;
for ($i = 0; $i < $this->info['chunkcount']; $i++) {
$total += $cache->size($prefix.$i);
}
return $total;
}
/**
* Removes all chunks which belong to this transmission
*/
public function cleanup() {
$cache = $this->getCache();
$prefix = $this->getPrefix();
for ($i = 0; $i < $this->info['chunkcount']; $i++) {
$cache->remove($prefix.$i);
}
}
/**
* Removes one specific chunk
* @param string $index
*/
public function remove($index) {
$cache = $this->getCache();
$prefix = $this->getPrefix();
$cache->remove($prefix.$index);
}
/**
* Assembles the chunks into the file specified by the path.
* Also triggers the relevant hooks and proxies.
*
* @param \OC\Files\Storage\Storage $storage storage
* @param string $path target path relative to the storage
* @return bool true on success or false if file could not be created
*
* @throws \OC\ServerNotAvailableException
*/
public function file_assemble($storage, $path) {
// use file_put_contents as method because that best matches what this function does
if (\OC\Files\Filesystem::isValidPath($path)) {
$target = $storage->fopen($path, 'w');
if ($target) {
$count = $this->assemble($target);
fclose($target);
return $count > 0;
} else {
return false;
}
}
return false;
}
}

@ -0,0 +1,72 @@
<?php
/**
* @author Roeland Jago Douma <rullzer@owncloud.com>
*
* @copyright Copyright (c) 2016, ownCloud, Inc.
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace Test;
use OCP\ICache;
class FileChunkingTest extends \Test\TestCase {
public function dataIsComplete() {
return [
[1, [], false],
[1, [0], true],
[2, [], false],
[2, [0], false],
[2, [1], false],
[2, [0,1], true],
[10, [], false],
[10, [0,1,2,3,4,5,6,7,8], false],
[10, [1,2,3,4,5,6,7,8,9], false],
[10, [0,1,2,3,5,6,7,8,9], false],
[10, [0,1,2,3,4,5,6,7,8,9], true],
];
}
/**
* @dataProvider dataIsComplete
* @param $total
* @param array $present
* @param $expected
*/
public function testIsComplete($total, array $present, $expected) {
$fileChunking = $this->getMockBuilder(\OC_FileChunking::class)
->setMethods(['getCache'])
->setConstructorArgs([[
'name' => 'file',
'transferid' => '42',
'chunkcount' => $total,
]])
->getMock();
$cache = $this->createMock(ICache::class);
$cache->expects($this->atLeastOnce())
->method('hasKey')
->willReturnCallback(function ($key) use ($present) {
$data = explode('-', $key);
return in_array($data[3], $present);
});
$fileChunking->method('getCache')->willReturn($cache);
$this->assertEquals($expected, $fileChunking->isComplete());
}
}
Loading…
Cancel
Save