Compare commits

...

3 Commits

Author SHA1 Message Date
Lance Pioch
ea978f3485 Reuse GuzzleClient with timeouts for presigned S3 requests 2026-02-07 23:17:12 -05:00
Lance Pioch
c8dcff2ee9 Fix phpstan 2026-02-06 18:14:01 -05:00
Lance Pioch
0579fd6df5 Use presigned URLs for all S3 operations (#1022)
Some S3-compatible providers (e.g., Hetzner) require presigned requests.
Previously only GetObject and UploadPart were presigned; the remaining
operations (CreateMultipartUpload, ListParts, CompleteMultipartUpload,
AbortMultipartUpload, DeleteObject) used header-based auth. This adds an
executeS3Command() method to S3Filesystem that sends all commands via
presigned URLs with manual XML response parsing, giving universal
compatibility with all S3-compatible providers.
2026-02-06 11:16:12 -05:00
6 changed files with 106 additions and 13 deletions

View File

@@ -2,11 +2,18 @@
namespace App\Extensions\Filesystem;
use Aws\CommandInterface;
use Aws\Result;
use Aws\S3\S3ClientInterface;
use GuzzleHttp\Client as GuzzleClient;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use RuntimeException;
use SimpleXMLElement;
class S3Filesystem extends AwsS3V3Adapter
{
private ?GuzzleClient $guzzle = null;
/**
* @param array<mixed> $options
*/
@@ -26,6 +33,18 @@ class S3Filesystem extends AwsS3V3Adapter
);
}
private function getGuzzleClient(): GuzzleClient
{
if ($this->guzzle === null) {
$this->guzzle = new GuzzleClient([
'timeout' => 30,
'connect_timeout' => 10,
]);
}
return $this->guzzle;
}
public function getClient(): S3ClientInterface
{
return $this->client;
@@ -35,4 +54,78 @@ class S3Filesystem extends AwsS3V3Adapter
{
return $this->bucket;
}
/**
* Execute an S3 command using a presigned URL for maximum compatibility
* with S3-compatible providers.
*
* @return Result<array<string, mixed>>
*/
public function executeS3Command(CommandInterface $command): Result
{
$presignedRequest = $this->client->createPresignedRequest($command, '+60 minutes');
$response = $this->getGuzzleClient()->send($presignedRequest);
$body = (string) $response->getBody();
$commandName = $command->getName();
// S3's CompleteMultipartUpload can return HTTP 200 with an <Error> body
if ($body !== '' && str_contains($body, '<Error>')) {
throw new RuntimeException("S3 returned an error for $commandName: $body");
}
return new Result($this->parseS3Response($commandName, $body));
}
/**
* Parse the XML response body based on the S3 command type.
*
* @return array<string, mixed>
*/
private function parseS3Response(string $commandName, string $body): array
{
if ($body === '') {
return [];
}
$xml = @simplexml_load_string($body);
if ($xml === false) {
throw new RuntimeException("Failed to parse S3 XML response for $commandName: $body");
}
return match ($commandName) {
'CreateMultipartUpload' => $this->parseCreateMultipartUpload($xml),
'ListParts' => $this->parseListParts($xml),
'CompleteMultipartUpload' => [],
default => [],
};
}
/**
* @return array{UploadId: string}
*/
private function parseCreateMultipartUpload(SimpleXMLElement $xml): array
{
return [
'UploadId' => (string) $xml->UploadId,
];
}
/**
* @return array{Parts: array<int, array{ETag: string, PartNumber: int}>}
*/
private function parseListParts(SimpleXMLElement $xml): array
{
$parts = [];
foreach ($xml->Part as $part) {
$parts[] = [
'ETag' => (string) $part->ETag,
'PartNumber' => (int) $part->PartNumber,
];
}
return ['Parts' => $parts];
}
}

View File

@@ -91,7 +91,7 @@ class BackupRemoteUploadController extends Controller
}
// Execute the CreateMultipartUpload request
$result = $client->execute($client->getCommand('CreateMultipartUpload', $params));
$result = $adapter->executeS3Command($client->getCommand('CreateMultipartUpload', $params));
// Get the UploadId from the CreateMultipartUpload request, this is needed to create
// the other presigned urls.

View File

@@ -138,7 +138,7 @@ class BackupStatusController extends Controller
$client = $adapter->getClient();
if (!$successful) {
$client->execute($client->getCommand('AbortMultipartUpload', $params));
$adapter->executeS3Command($client->getCommand('AbortMultipartUpload', $params));
return;
}
@@ -149,7 +149,7 @@ class BackupStatusController extends Controller
];
if (is_null($parts)) {
$params['MultipartUpload']['Parts'] = $client->execute($client->getCommand('ListParts', $params))['Parts'];
$params['MultipartUpload']['Parts'] = $adapter->executeS3Command($client->getCommand('ListParts', $params))['Parts'];
} else {
foreach ($parts as $part) {
$params['MultipartUpload']['Parts'][] = [
@@ -159,6 +159,6 @@ class BackupStatusController extends Controller
}
}
$client->execute($client->getCommand('CompleteMultipartUpload', $params));
$adapter->executeS3Command($client->getCommand('CompleteMultipartUpload', $params));
}
}

View File

@@ -10,7 +10,6 @@ use Filament\Facades\Filament;
use Illuminate\Contracts\Auth\Factory as AuthFactory;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Request;
use Throwable;
@@ -71,7 +70,7 @@ class ActivityLogService
*/
public function subject(...$subjects): self
{
foreach (Arr::wrap($subjects) as $subject) {
foreach ($subjects as $subject) {
if (is_null($subject)) {
continue;
}

View File

@@ -7,7 +7,6 @@ use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem;
use App\Models\Backup;
use App\Repositories\Daemon\DaemonBackupRepository;
use Aws\S3\S3Client;
use Exception;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Http\Response;
@@ -72,14 +71,12 @@ class DeleteBackupService
/** @var S3Filesystem $adapter */
$adapter = $this->manager->adapter(Backup::ADAPTER_AWS_S3);
/** @var S3Client $client */
$client = $adapter->getClient();
$client->deleteObject([
$adapter->executeS3Command($client->getCommand('DeleteObject', [
'Bucket' => $adapter->getBucket(),
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
]);
]));
});
}
}

View File

@@ -9,8 +9,10 @@ use App\Models\Backup;
use App\Repositories\Daemon\DaemonBackupRepository;
use App\Services\Backups\DeleteBackupService;
use App\Tests\Integration\IntegrationTestCase;
use Aws\CommandInterface;
use GuzzleHttp\Psr7\Response;
use Illuminate\Http\Client\ConnectionException;
use Mockery;
class DeleteBackupServiceTest extends IntegrationTestCase
{
@@ -92,10 +94,12 @@ class DeleteBackupServiceTest extends IntegrationTestCase
$manager->expects('adapter')->with(Backup::ADAPTER_AWS_S3)->andReturn($adapter);
$adapter->expects('getBucket')->andReturn('foobar');
$adapter->expects('getClient->deleteObject')->with([
$mockCommand = Mockery::mock(CommandInterface::class);
$adapter->expects('getClient->getCommand')->with('DeleteObject', [
'Bucket' => 'foobar',
'Key' => sprintf('%s/%s.tar.gz', $server->uuid, $backup->uuid),
]);
])->andReturn($mockCommand);
$adapter->expects('executeS3Command')->with($mockCommand);
$this->app->make(DeleteBackupService::class)->handle($backup);