Fix parallel storage chunk upload state

This commit is contained in:
Torsten Dittmann
2026-05-04 14:15:01 +04:00
parent 554fdf5472
commit 61ca4e3969
4 changed files with 471 additions and 189 deletions
+7
View File
@@ -72,6 +72,7 @@
"utopia-php/validators": "0.2.*",
"utopia-php/image": "0.8.*",
"utopia-php/locale": "0.8.*",
"utopia-php/lock": "dev-main",
"utopia-php/logger": "0.6.*",
"utopia-php/messaging": "0.22.*",
"utopia-php/migration": "1.9.*",
@@ -111,6 +112,12 @@
"provide": {
"ext-phpiredis": "*"
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/utopia-php/lock"
}
],
"config": {
"platform": {
},
Generated
+77 -2
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bd45829c252971301370d62300be106d",
"content-hash": "fab1d5b01931f0e2545c36c50b3963b7",
"packages": [
{
"name": "adhocore/jwt",
@@ -4423,6 +4423,79 @@
},
"time": "2025-08-12T12:58:26+00:00"
},
{
"name": "utopia-php/lock",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/lock.git",
"reference": "22eda789528da4cc71e767c9ad9e9fd4fa2c9467"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/lock/zipball/22eda789528da4cc71e767c9ad9e9fd4fa2c9467",
"reference": "22eda789528da4cc71e767c9ad9e9fd4fa2c9467",
"shasum": ""
},
"require": {
"php": ">=8.3"
},
"require-dev": {
"laravel/pint": "1.*",
"phpstan/phpstan": "2.*",
"phpunit/phpunit": "11.*",
"swoole/ide-helper": "*"
},
"suggest": {
"ext-pcntl": "Required to run the File lock tests",
"ext-redis": "Required for the Distributed lock",
"ext-swoole": "Required for the Mutex and Semaphore locks (>=6.0)"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"Utopia\\Lock\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Utopia\\Lock\\Tests\\": "tests/"
}
},
"scripts": {
"test": [
"vendor/bin/phpunit"
],
"lint": [
"vendor/bin/pint --test"
],
"format": [
"vendor/bin/pint"
],
"format:check": [
"vendor/bin/pint --test"
],
"analyze": [
"vendor/bin/phpstan analyse --memory-limit=512M"
]
},
"license": [
"MIT"
],
"authors": [
{
"name": "Appwrite Team",
"email": "team@appwrite.io"
}
],
"description": "A simple lock library to coordinate access to shared resources across coroutines, processes and hosts",
"support": {
"source": "https://github.com/utopia-php/lock/tree/main",
"issues": "https://github.com/utopia-php/lock/issues"
},
"time": "2026-04-28T10:07:10+00:00"
},
{
"name": "utopia-php/logger",
"version": "0.6.2",
@@ -8444,7 +8517,9 @@
],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {},
"stability-flags": {
"utopia-php/lock": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@@ -29,6 +29,8 @@ use Utopia\Database\Validator\Authorization\Input;
use Utopia\Database\Validator\Permissions;
use Utopia\Database\Validator\UID;
use Utopia\Http\Adapter\Swoole\Request;
use Utopia\Lock\Distributed;
use Utopia\Lock\Exception\Contention as LockContention;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
@@ -86,12 +88,14 @@ class Create extends Action
->inject('request')
->inject('response')
->inject('dbForProject')
->inject('project')
->inject('user')
->inject('queueForEvents')
->inject('mode')
->inject('deviceForFiles')
->inject('deviceForLocal')
->inject('authorization')
->inject('redis')
->callback($this->action(...));
}
@@ -103,12 +107,14 @@ class Create extends Action
Request $request,
Response $response,
Database $dbForProject,
Document $project,
User $user,
Event $queueForEvents,
string $mode,
Device $deviceForFiles,
Device $deviceForLocal,
Authorization $authorization
Authorization $authorization,
\Redis $redis
) {
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));
@@ -234,24 +240,43 @@ class Create extends Action
$path = $deviceForFiles->getPath($fileId . '.' . \pathinfo($fileName, PATHINFO_EXTENSION));
$path = str_ireplace($deviceForFiles->getRoot(), $deviceForFiles->getRoot() . DIRECTORY_SEPARATOR . $bucket->getId(), $path); // Add bucket id to path after root
$file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId);
$lock = new Distributed(
$redis,
'storage:file:' . $project->getId() . ':' . $bucket->getId() . ':' . $fileId,
ttl: 600,
);
$metadata = ['content_type' => $deviceForLocal->getFileMimeType($fileTmpName)];
if (!$file->isEmpty()) {
$chunks = $file->getAttribute('chunksTotal', 1);
$uploaded = $file->getAttribute('chunksUploaded', 0);
$metadata = $file->getAttribute('metadata', []);
$completed = false;
if ($uploaded === $chunks) {
if (empty($contentRange)) {
throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
try {
$lock->withLock(function () use ($bucket, &$chunks, $contentRange, $dbForProject, $fileId, &$metadata, &$completed, $response): void {
$file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId);
if (!$file->isEmpty()) {
$chunks = $file->getAttribute('chunksTotal', 1);
$uploaded = $file->getAttribute('chunksUploaded', 0);
$metadata = $file->getAttribute('metadata', []);
if ($uploaded === $chunks) {
if (empty($contentRange)) {
throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
}
$response
->setStatusCode(Response::STATUS_CODE_OK)
->dynamic($file, Response::MODEL_FILE);
$completed = true;
return;
}
}
}, timeout: 120.0);
} catch (LockContention) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'File upload is busy. Try again.');
}
$response
->setStatusCode(Response::STATUS_CODE_OK)
->dynamic($file, Response::MODEL_FILE);
return;
}
if ($completed) {
return;
}
$chunksUploaded = $deviceForFiles->upload($fileTmpName, $path, $chunk, $chunks, $metadata);
@@ -260,187 +285,211 @@ class Create extends Action
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed uploading file');
}
if ($chunksUploaded === $chunks) {
if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antivirus', true) && $fileSize <= APP_LIMIT_ANTIVIRUS && $deviceForFiles->getType() === Storage::DEVICE_LOCAL) {
$antivirus = new Network(
System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'),
(int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310)
);
try {
$lock->withLock(function () use ($authorization, $bucket, &$chunks, $chunksUploaded, $contentRange, $dbForProject, $deviceForFiles, $fileId, $fileName, $fileSize, &$metadata, $path, $permissions, $queueForEvents, $response): void {
$file = $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId);
if (!$antivirus->fileScan($path)) {
$deviceForFiles->delete($path);
throw new Exception(Exception::STORAGE_INVALID_FILE);
if (!$file->isEmpty()) {
$chunks = $file->getAttribute('chunksTotal', 1);
$uploaded = $file->getAttribute('chunksUploaded', 0);
$metadata = \array_merge($file->getAttribute('metadata', []), $metadata);
if ($uploaded === $chunks) {
if (empty($contentRange)) {
throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
}
$response
->setStatusCode(Response::STATUS_CODE_OK)
->dynamic($file, Response::MODEL_FILE);
return;
}
}
}
$mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption
$fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption
$data = '';
$iv = '';
$tag = null;
// Compression
$algorithm = $bucket->getAttribute('compression', Compression::NONE);
if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) {
$data = $deviceForFiles->read($path);
switch ($algorithm) {
case Compression::ZSTD:
$compressor = new Zstd();
break;
case Compression::GZIP:
default:
$compressor = new GZIP();
break;
if ($chunksUploaded === $chunks) {
if (System::getEnv('_APP_STORAGE_ANTIVIRUS') === 'enabled' && $bucket->getAttribute('antivirus', true) && $fileSize <= APP_LIMIT_ANTIVIRUS && $deviceForFiles->getType() === Storage::DEVICE_LOCAL) {
$antivirus = new Network(
System::getEnv('_APP_STORAGE_ANTIVIRUS_HOST', 'clamav'),
(int) System::getEnv('_APP_STORAGE_ANTIVIRUS_PORT', 3310)
);
if (!$antivirus->fileScan($path)) {
$deviceForFiles->delete($path);
throw new Exception(Exception::STORAGE_INVALID_FILE);
}
}
$mimeType = $deviceForFiles->getFileMimeType($path); // Get mime-type before compression and encryption
$fileHash = $deviceForFiles->getFileHash($path); // Get file hash before compression and encryption
$data = '';
$iv = '';
$tag = null;
// Compression
$algorithm = $bucket->getAttribute('compression', Compression::NONE);
if ($fileSize <= APP_STORAGE_READ_BUFFER && $algorithm != Compression::NONE) {
$data = $deviceForFiles->read($path);
switch ($algorithm) {
case Compression::ZSTD:
$compressor = new Zstd();
break;
case Compression::GZIP:
default:
$compressor = new GZIP();
break;
}
$data = $compressor->compress($data);
} else {
// reset the algorithm to none as we do not compress the file
// if file size exceedes the APP_STORAGE_READ_BUFFER
// regardless the bucket compression algoorithm
$algorithm = Compression::NONE;
}
if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) {
if (empty($data)) {
$data = $deviceForFiles->read($path);
}
$key = System::getEnv('_APP_OPENSSL_KEY_V1');
$iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM));
$data = OpenSSL::encrypt($data, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag);
}
if (!empty($data)) {
if (!$deviceForFiles->write($path, $data, $mimeType)) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to save file');
}
}
$sizeActual = $deviceForFiles->getFileSize($path);
$openSSLVersion = null;
$openSSLCipher = null;
$openSSLTag = null;
$openSSLIV = null;
if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) {
$openSSLVersion = '1';
$openSSLCipher = OpenSSL::CIPHER_AES_128_GCM;
$openSSLTag = \bin2hex($tag);
$openSSLIV = \bin2hex($iv);
}
if ($file->isEmpty()) {
$doc = new Document([
'$id' => $fileId,
'$permissions' => $permissions,
'bucketId' => $bucket->getId(),
'bucketInternalId' => $bucket->getSequence(),
'name' => $fileName,
'path' => $path,
'signature' => $fileHash,
'mimeType' => $mimeType,
'sizeOriginal' => $fileSize,
'sizeActual' => $sizeActual,
'algorithm' => $algorithm,
'comment' => '',
'chunksTotal' => $chunks,
'chunksUploaded' => $chunksUploaded,
'openSSLVersion' => $openSSLVersion,
'openSSLCipher' => $openSSLCipher,
'openSSLTag' => $openSSLTag,
'openSSLIV' => $openSSLIV,
'search' => implode(' ', [$fileId, $fileName]),
'metadata' => $metadata,
]);
try {
$file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc);
} catch (DuplicateException) {
throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
} catch (NotFoundException) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
} else {
/**
* Skip authorization in updateDocument.
* Without this, the file creation will fail when user doesn't have update permission.
* However as with chunk upload even if we are updating, we are essentially creating a file
* adding it's new chunk so we rely on the create-permission check performed earlier.
*/
$file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([
'$permissions' => $permissions,
'signature' => $fileHash,
'mimeType' => $mimeType,
'sizeActual' => $sizeActual,
'algorithm' => $algorithm,
'openSSLVersion' => $openSSLVersion,
'openSSLCipher' => $openSSLCipher,
'openSSLTag' => $openSSLTag,
'openSSLIV' => $openSSLIV,
'metadata' => $metadata,
'chunksUploaded' => $chunksUploaded,
])));
}
// Trigger after create success hook
$this->afterCreateSuccess($file);
} else {
if ($file->isEmpty()) {
$doc = new Document([
'$id' => ID::custom($fileId),
'$permissions' => $permissions,
'bucketId' => $bucket->getId(),
'bucketInternalId' => $bucket->getSequence(),
'name' => $fileName,
'path' => $path,
'signature' => '',
'mimeType' => '',
'sizeOriginal' => $fileSize,
'sizeActual' => 0,
'algorithm' => '',
'comment' => '',
'chunksTotal' => $chunks,
'chunksUploaded' => $chunksUploaded,
'search' => implode(' ', [$fileId, $fileName]),
'metadata' => $metadata,
]);
try {
$file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc);
} catch (DuplicateException) {
throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
} catch (NotFoundException) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
} else {
/**
* Skip authorization in updateDocument.
* Without this, the file creation will fail when user doesn't have update permission.
* However as with chunk upload even if we are updating, we are essentially creating a file
* adding it's new chunk so we rely on the create-permission check performed earlier.
*/
try {
$file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, new Document([
'chunksUploaded' => $chunksUploaded,
'metadata' => $metadata,
])));
} catch (NotFoundException) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
}
}
$data = $compressor->compress($data);
} else {
// reset the algorithm to none as we do not compress the file
// if file size exceedes the APP_STORAGE_READ_BUFFER
// regardless the bucket compression algoorithm
$algorithm = Compression::NONE;
}
if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) {
if (empty($data)) {
$data = $deviceForFiles->read($path);
}
$key = System::getEnv('_APP_OPENSSL_KEY_V1');
$iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM));
$data = OpenSSL::encrypt($data, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag);
}
$queueForEvents
->setParam('bucketId', $bucket->getId())
->setParam('fileId', $file->getId())
->setContext('bucket', $bucket);
if (!empty($data)) {
if (!$deviceForFiles->write($path, $data, $mimeType)) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to save file');
}
}
$metadata = null; // was causing leaks as it was passed by reference
$sizeActual = $deviceForFiles->getFileSize($path);
$openSSLVersion = null;
$openSSLCipher = null;
$openSSLTag = null;
$openSSLIV = null;
if ($bucket->getAttribute('encryption', true) && $fileSize <= APP_STORAGE_READ_BUFFER) {
$openSSLVersion = '1';
$openSSLCipher = OpenSSL::CIPHER_AES_128_GCM;
$openSSLTag = \bin2hex($tag);
$openSSLIV = \bin2hex($iv);
}
if ($file->isEmpty()) {
$doc = new Document([
'$id' => $fileId,
'$permissions' => $permissions,
'bucketId' => $bucket->getId(),
'bucketInternalId' => $bucket->getSequence(),
'name' => $fileName,
'path' => $path,
'signature' => $fileHash,
'mimeType' => $mimeType,
'sizeOriginal' => $fileSize,
'sizeActual' => $sizeActual,
'algorithm' => $algorithm,
'comment' => '',
'chunksTotal' => $chunks,
'chunksUploaded' => $chunksUploaded,
'openSSLVersion' => $openSSLVersion,
'openSSLCipher' => $openSSLCipher,
'openSSLTag' => $openSSLTag,
'openSSLIV' => $openSSLIV,
'search' => implode(' ', [$fileId, $fileName]),
'metadata' => $metadata,
]);
try {
$file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc);
} catch (DuplicateException) {
throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
} catch (NotFoundException) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
} else {
$file = $file
->setAttribute('$permissions', $permissions)
->setAttribute('signature', $fileHash)
->setAttribute('mimeType', $mimeType)
->setAttribute('sizeActual', $sizeActual)
->setAttribute('algorithm', $algorithm)
->setAttribute('openSSLVersion', $openSSLVersion)
->setAttribute('openSSLCipher', $openSSLCipher)
->setAttribute('openSSLTag', $openSSLTag)
->setAttribute('openSSLIV', $openSSLIV)
->setAttribute('metadata', $metadata)
->setAttribute('chunksUploaded', $chunksUploaded);
/**
* Skip authorization in updateDocument.
* Without this, the file creation will fail when user doesn't have update permission.
* However as with chunk upload even if we are updating, we are essentially creating a file
* adding it's new chunk so we rely on the create-permission check performed earlier.
*/
$file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file));
}
// Trigger after create success hook
$this->afterCreateSuccess($file);
} else {
if ($file->isEmpty()) {
$doc = new Document([
'$id' => ID::custom($fileId),
'$permissions' => $permissions,
'bucketId' => $bucket->getId(),
'bucketInternalId' => $bucket->getSequence(),
'name' => $fileName,
'path' => $path,
'signature' => '',
'mimeType' => '',
'sizeOriginal' => $fileSize,
'sizeActual' => 0,
'algorithm' => '',
'comment' => '',
'chunksTotal' => $chunks,
'chunksUploaded' => $chunksUploaded,
'search' => implode(' ', [$fileId, $fileName]),
'metadata' => $metadata,
]);
try {
$file = $dbForProject->createDocument('bucket_' . $bucket->getSequence(), $doc);
} catch (DuplicateException) {
throw new Exception(Exception::STORAGE_FILE_ALREADY_EXISTS);
} catch (NotFoundException) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
} else {
$file = $file
->setAttribute('chunksUploaded', $chunksUploaded)
->setAttribute('metadata', $metadata);
/**
* Skip authorization in updateDocument.
* Without this, the file creation will fail when user doesn't have update permission.
* However as with chunk upload even if we are updating, we are essentially creating a file
* adding it's new chunk so we rely on the create-permission check performed earlier.
*/
try {
$file = $authorization->skip(fn () => $dbForProject->updateDocument('bucket_' . $bucket->getSequence(), $fileId, $file));
} catch (NotFoundException) {
throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND);
}
}
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($file, Response::MODEL_FILE);
}, timeout: 120.0);
} catch (LockContention) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'File upload is busy. Try again.');
}
$queueForEvents
->setParam('bucketId', $bucket->getId())
->setParam('fileId', $file->getId())
->setContext('bucket', $bucket);
$metadata = null; // was causing leaks as it was passed by reference
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($file, Response::MODEL_FILE);
}
/**
+151
View File
@@ -1374,6 +1374,157 @@ trait StorageBase
]);
}
public function testCreateBucketFileParallelChunksLargeFile(): void
{
$totalSize = (int) ($_ENV['APPWRITE_TEST_PARALLEL_UPLOAD_SIZE'] ?? 20 * 1024 * 1024);
$chunkSize = 5 * 1024 * 1024;
$chunksTotal = (int) ceil($totalSize / $chunkSize);
$this->assertGreaterThanOrEqual(4, $chunksTotal, 'Test file must span at least 4 chunks');
$bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], [
'bucketId' => ID::unique(),
'name' => 'Test Bucket Parallel Chunk Upload',
'fileSecurity' => true,
'maximumFileSize' => $totalSize,
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $bucket['headers']['status-code']);
$bucketId = $bucket['body']['$id'];
$fileId = ID::unique();
$tmpDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'appwrite-parallel-upload-' . $fileId;
$source = $tmpDirectory . DIRECTORY_SEPARATOR . 'large-parallel-upload.bin';
mkdir($tmpDirectory);
try {
$handle = fopen($source, 'wb');
$this->assertNotFalse($handle, 'Could not create test file');
$remaining = $totalSize;
$block = str_repeat(hash('sha256', $fileId, binary: true), 1024);
while ($remaining > 0) {
$bytes = substr($block, 0, min(strlen($block), $remaining));
fwrite($handle, $bytes);
$remaining -= strlen($bytes);
}
fclose($handle);
$multi = curl_multi_init();
$handles = [];
$sourceHandle = fopen($source, 'rb');
$this->assertNotFalse($sourceHandle, 'Could not open test file');
for ($i = 0; $i < $chunksTotal; $i++) {
$start = $i * $chunkSize;
$end = min($start + $chunkSize, $totalSize) - 1;
$length = $end - $start + 1;
$chunkPath = $tmpDirectory . DIRECTORY_SEPARATOR . 'chunk-' . $i . '.part';
fseek($sourceHandle, $start);
file_put_contents($chunkPath, fread($sourceHandle, $length));
$headers = array_merge([
'content-type' => 'multipart/form-data',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
'content-range' => 'bytes ' . $start . '-' . $end . '/' . $totalSize,
]);
$formattedHeaders = [];
foreach ($headers as $key => $value) {
$formattedHeaders[] = $key . ': ' . $value;
}
$ch = curl_init($this->client->getEndpoint() . '/storage/buckets/' . $bucketId . '/files');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, Client::METHOD_POST);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPHEADER, $formattedHeaders);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
'fileId' => $fileId,
'file' => new CURLFile($chunkPath, 'application/octet-stream', 'large-parallel-upload.bin'),
'permissions[0]' => Permission::read(Role::any()),
'permissions[1]' => Permission::delete(Role::any()),
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 300);
$handles[] = $ch;
curl_multi_add_handle($multi, $ch);
}
fclose($sourceHandle);
do {
$status = curl_multi_exec($multi, $running);
if ($running > 0) {
curl_multi_select($multi, 1.0);
}
} while ($running > 0 && $status === CURLM_OK);
foreach ($handles as $ch) {
$body = curl_multi_getcontent($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_multi_remove_handle($multi, $ch);
$this->assertSame('', $error);
$this->assertContains($statusCode, [200, 201], $body);
}
curl_multi_close($multi);
$uploadedFile = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals(200, $uploadedFile['headers']['status-code']);
$this->assertEquals($chunksTotal, $uploadedFile['body']['chunksTotal']);
$this->assertEquals($chunksTotal, $uploadedFile['body']['chunksUploaded']);
$download = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/download', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals(200, $download['headers']['status-code']);
$this->assertEquals($totalSize, strlen($download['body']));
$this->assertEquals(hash_file('sha256', $source), hash('sha256', $download['body']));
} finally {
if (isset($bucketId)) {
$this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]);
}
foreach (glob($tmpDirectory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) {
unlink($file);
}
if (is_dir($tmpDirectory)) {
rmdir($tmpDirectory);
}
}
}
public function testDeleteBucketFile(): void
{
// Create a fresh file just for deletion testing (not using cache since we delete it)