From 898e8e214ba698e2819b730b1196d8bf998a1981 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:04:41 +0000 Subject: [PATCH 1/4] fix: allow deletion of partially-uploaded (pending) files and add test Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/8d14b17a-78d8-48c6-b6d8-51d9697adde0 Co-authored-by: Meldiron <19310830+Meldiron@users.noreply.github.com> --- .../Storage/Http/Buckets/Files/Delete.php | 15 +++- tests/e2e/Services/Storage/StorageBase.php | 81 +++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php index ca376842e2..596e21de26 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php @@ -108,10 +108,17 @@ class Delete extends Action $deviceDeleted = false; if ($file->getAttribute('chunksTotal') !== $file->getAttribute('chunksUploaded')) { - $deviceDeleted = $deviceForFiles->abort( - $file->getAttribute('path'), - ($file->getAttribute('metadata', [])['uploadId'] ?? '') - ); + try { + $deviceDeleted = $deviceForFiles->abort( + $file->getAttribute('path'), + ($file->getAttribute('metadata', [])['uploadId'] ?? '') + ); + } catch (\Exception) { + // If the partial upload chunks are already gone from the device + // (e.g. the upload never wrote anything to disk), treat it as deleted + // so the pending file document can still be removed from the database. + $deviceDeleted = true; + } } else { $deviceDeleted = $deviceForFiles->delete($file->getAttribute('path')); } diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 9f4105e1cb..453cd97605 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1069,6 +1069,87 @@ trait StorageBase $this->assertNotEmpty($preview['body']); } + public function testDeletePartiallyUploadedFile(): void + { + // Create a bucket for this test + $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 Partial Upload', + 'fileSecurity' => true, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $bucket['headers']['status-code']); + $bucketId = $bucket['body']['$id']; + + // Simulate a partial (cancelled) chunked upload by sending only the first chunk + $source = __DIR__ . "/../../../resources/disk-a/large-file.mp4"; + $totalSize = \filesize($source); + $chunkSize = 5 * 1024 * 1024; // 5MB chunks + $mimeType = mime_content_type($source); + + $handle = @fopen($source, "rb"); + $chunkData = @fread($handle, $chunkSize); + @fclose($handle); + + $curlFile = new \CURLFile( + 'data://' . $mimeType . ';base64,' . base64_encode($chunkData), + $mimeType, + 'large-file.mp4' + ); + + // Send only the first chunk (bytes 0 to chunkSize-1 of totalSize) + $end = min($chunkSize - 1, $totalSize - 1); + $partialFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . $end . '/' . $totalSize, + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => $curlFile, + 'permissions' => [ + Permission::read(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $partialFile['headers']['status-code']); + $fileId = $partialFile['body']['$id']; + + // Confirm the file is in a pending state (chunksTotal > chunksUploaded) + $this->assertGreaterThan( + $partialFile['body']['chunksUploaded'], + $partialFile['body']['chunksTotal'], + 'File should be partially uploaded (pending)' + ); + + // Delete the partially-uploaded (pending) file — this should succeed + $deleteResponse = $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $deleteResponse['headers']['status-code']); + $this->assertEmpty($deleteResponse['body']); + + // Confirm the file is gone + $getResponse = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $getResponse['headers']['status-code']); + } + public function testDeleteBucketFile(): void { // Create a fresh file just for deletion testing (not using cache since we delete it) From ecc76c752087b438c1befbcf2ca7d33afc26d7af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:34:19 +0000 Subject: [PATCH 2/4] fix: address review feedback - log abort exception, fix test cleanup Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/045e63f6-9a81-447b-ba79-7391ab97fb01 Co-authored-by: Meldiron <19310830+Meldiron@users.noreply.github.com> --- .../Storage/Http/Buckets/Files/Delete.php | 8 ++++++-- tests/e2e/Services/Storage/StorageBase.php | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php index 596e21de26..adecc820bb 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php @@ -16,6 +16,7 @@ use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Authorization\Input; use Utopia\Database\Validator\UID; +use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Storage\Device; @@ -66,6 +67,7 @@ class Delete extends Action ->inject('deviceForFiles') ->inject('queueForDeletes') ->inject('authorization') + ->inject('log') ->callback($this->action(...)); } @@ -77,7 +79,8 @@ class Delete extends Action Event $queueForEvents, Device $deviceForFiles, DeleteEvent $queueForDeletes, - Authorization $authorization + Authorization $authorization, + Log $log ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -113,10 +116,11 @@ class Delete extends Action $file->getAttribute('path'), ($file->getAttribute('metadata', [])['uploadId'] ?? '') ); - } catch (\Exception) { + } catch (\Exception $e) { // If the partial upload chunks are already gone from the device // (e.g. the upload never wrote anything to disk), treat it as deleted // so the pending file document can still be removed from the database. + $log->addTag('abortException', $e->getMessage()); $deviceDeleted = true; } } else { diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 453cd97605..100f5c5ca8 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1097,9 +1097,10 @@ trait StorageBase $chunkSize = 5 * 1024 * 1024; // 5MB chunks $mimeType = mime_content_type($source); - $handle = @fopen($source, "rb"); - $chunkData = @fread($handle, $chunkSize); - @fclose($handle); + $handle = fopen($source, "rb"); + $this->assertNotFalse($handle, "Could not open test resource: $source"); + $chunkData = fread($handle, $chunkSize); + fclose($handle); $curlFile = new \CURLFile( 'data://' . $mimeType . ';base64,' . base64_encode($chunkData), @@ -1148,6 +1149,15 @@ trait StorageBase ], $this->getHeaders())); $this->assertEquals(404, $getResponse['headers']['status-code']); + + // Clean up the test bucket + $deleteBucketResponse = $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'], + ]); + + $this->assertEquals(204, $deleteBucketResponse['headers']['status-code']); } public function testDeleteBucketFile(): void From b2b187c57d49063be66b40e00123fdaefad28832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 27 Mar 2026 12:35:48 +0100 Subject: [PATCH 3/4] review fixes --- .../Modules/Storage/Http/Buckets/Files/Delete.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php index adecc820bb..1068dc4d9d 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php @@ -16,9 +16,9 @@ use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Authorization\Input; use Utopia\Database\Validator\UID; -use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\Span\Span; use Utopia\Storage\Device; class Delete extends Action @@ -67,7 +67,7 @@ class Delete extends Action ->inject('deviceForFiles') ->inject('queueForDeletes') ->inject('authorization') - ->inject('log') + ->inject('span') ->callback($this->action(...)); } @@ -80,7 +80,7 @@ class Delete extends Action Device $deviceForFiles, DeleteEvent $queueForDeletes, Authorization $authorization, - Log $log + Span $span, ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -120,7 +120,7 @@ class Delete extends Action // If the partial upload chunks are already gone from the device // (e.g. the upload never wrote anything to disk), treat it as deleted // so the pending file document can still be removed from the database. - $log->addTag('abortException', $e->getMessage()); + $span->add('abortException', $e->getMessage()); $deviceDeleted = true; } } else { From 71e714c66be302f25a8ac1fed70eb3a0c483836b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 27 Mar 2026 12:49:16 +0100 Subject: [PATCH 4/4] Remove extra logging --- .../Platform/Modules/Storage/Http/Buckets/Files/Delete.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php index 1068dc4d9d..9bbc400168 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php @@ -18,7 +18,6 @@ use Utopia\Database\Validator\Authorization\Input; use Utopia\Database\Validator\UID; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -use Utopia\Span\Span; use Utopia\Storage\Device; class Delete extends Action @@ -67,7 +66,6 @@ class Delete extends Action ->inject('deviceForFiles') ->inject('queueForDeletes') ->inject('authorization') - ->inject('span') ->callback($this->action(...)); } @@ -80,7 +78,6 @@ class Delete extends Action Device $deviceForFiles, DeleteEvent $queueForDeletes, Authorization $authorization, - Span $span, ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); @@ -120,7 +117,6 @@ class Delete extends Action // If the partial upload chunks are already gone from the device // (e.g. the upload never wrote anything to disk), treat it as deleted // so the pending file document can still be removed from the database. - $span->add('abortException', $e->getMessage()); $deviceDeleted = true; } } else {