Merge pull request #1 from biondizzle/codex/implement-basic-s3-crud-operations

Add working S3 API
This commit is contained in:
biondizzle
2025-06-05 09:43:30 -04:00
committed by GitHub

View File

@@ -7,18 +7,53 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use App\Service\S3Service;
use App\Entity\S3Bucket;
use App\Entity\S3MultipartUpload;
class S3ApiController extends AbstractController class S3ApiController extends AbstractController
{ {
public function __construct(private S3Service $s3Service) {}
private function getCredential(Request $request, ?string $bucket = null, ?string $key = null)
{
$hash = $request->query->get('hash');
if ($hash) {
$url = $this->s3Service->findPresignedUrl($hash);
if ($url && !$url->isExpired() && strtoupper($url->getMethod()) === $request->getMethod()) {
if (($bucket === null || $bucket === $url->getBucketName()) && ($key === null || $key === $url->getObjectKey())) {
return $this->s3Service->findCredentialByAccessKey($url->getAccessKey());
}
}
}
$auth = $request->headers->get('Authorization');
if ($auth && preg_match('/Credential=([^\/]+)\//', $auth, $m)) {
return $this->s3Service->findCredentialByAccessKey($m[1]);
}
return null;
}
// Bucket Operations // Bucket Operations
public function listBuckets(Request $request): Response public function listBuckets(Request $request): Response
{ {
// TODO: Implement bucket listing logic $credential = $this->getCredential($request);
$buckets = []; // Get from your datastore if (!$credential) {
return new Response('Unauthorized', 401);
$xml = $this->generateListBucketsXml($buckets); }
$buckets = $this->s3Service->listBuckets($credential);
$items = array_map(function (S3Bucket $b) {
return [
'name' => $b->getName(),
'created' => $b->getCreatedAt()->format('Y-m-d\TH:i:s\.000\Z')
];
}, $buckets);
$xml = $this->generateListBucketsXml($items);
return new Response($xml, 200, [ return new Response($xml, 200, [
'Content-Type' => 'application/xml', 'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
@@ -27,10 +62,17 @@ class S3ApiController extends AbstractController
public function createBucket(string $bucket, Request $request): Response public function createBucket(string $bucket, Request $request): Response
{ {
// TODO: Validate bucket name $credential = $this->getCredential($request, $bucket);
// TODO: Check if bucket already exists if (!$credential) {
// TODO: Create bucket in datastore return new Response('Unauthorized', 401);
}
if ($this->s3Service->findBucketByName($bucket)) {
return new Response('', 409);
}
$this->s3Service->createBucket($bucket, $credential);
return new Response('', 200, [ return new Response('', 200, [
'Location' => "/$bucket", 'Location' => "/$bucket",
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
@@ -39,10 +81,22 @@ class S3ApiController extends AbstractController
public function deleteBucket(string $bucket, Request $request): Response public function deleteBucket(string $bucket, Request $request): Response
{ {
// TODO: Check if bucket exists $credential = $this->getCredential($request, $bucket);
// TODO: Check if bucket is empty if (!$credential) {
// TODO: Delete bucket from datastore return new Response('Unauthorized', 401);
}
$bucketObj = $this->s3Service->findBucketByName($bucket);
if (!$bucketObj) {
return new Response('', 404);
}
try {
$this->s3Service->deleteBucket($bucketObj);
} catch (\Exception $e) {
return new Response('', 409);
}
return new Response('', 204, [ return new Response('', 204, [
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
]); ]);
@@ -50,8 +104,11 @@ class S3ApiController extends AbstractController
public function headBucket(string $bucket, Request $request): Response public function headBucket(string $bucket, Request $request): Response
{ {
// TODO: Check if bucket exists $bucketObj = $this->s3Service->findBucketByName($bucket);
if (!$bucketObj) {
return new Response('', 404, ['x-amz-request-id' => uniqid()]);
}
return new Response('', 200, [ return new Response('', 200, [
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
]); ]);
@@ -63,12 +120,29 @@ class S3ApiController extends AbstractController
$marker = $request->query->get('marker', ''); $marker = $request->query->get('marker', '');
$maxKeys = (int) $request->query->get('max-keys', 1000); $maxKeys = (int) $request->query->get('max-keys', 1000);
$delimiter = $request->query->get('delimiter'); $delimiter = $request->query->get('delimiter');
// TODO: Get objects from datastore based on parameters $credential = $this->getCredential($request, $bucket);
$objects = []; if (!$credential) {
return new Response('Unauthorized', 401);
$xml = $this->generateListObjectsXml($bucket, $objects, $prefix, $marker, $maxKeys, $delimiter); }
$bucketObj = $this->s3Service->findBucketByName($bucket);
if (!$bucketObj) {
return new Response('', 404);
}
$objects = $this->s3Service->listObjects($bucketObj, $prefix, $marker, $maxKeys, $delimiter);
$items = array_map(function ($o) {
return [
'key' => $o->getObjectKey(),
'etag' => $o->getEtag(),
'size' => $o->getSize(),
'modified' => $o->getUpdatedAt()->format('Y-m-d\TH:i:s\.000\Z')
];
}, $objects);
$xml = $this->generateListObjectsXml($bucket, $items, $prefix, $marker, $maxKeys, $delimiter);
return new Response($xml, 200, [ return new Response($xml, 200, [
'Content-Type' => 'application/xml', 'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
@@ -79,56 +153,96 @@ class S3ApiController extends AbstractController
public function putObject(string $bucket, string $key, Request $request): Response public function putObject(string $bucket, string $key, Request $request): Response
{ {
// TODO: Validate bucket exists $credential = $this->getCredential($request, $bucket, $key);
// TODO: Get request body/stream if (!$credential) {
// TODO: Store object in datastore return new Response('Unauthorized', 401);
}
$etag = md5($request->getContent()); // Simplified - use actual content hash
$bucketObj = $this->s3Service->findBucketByName($bucket);
if (!$bucketObj) {
return new Response('', 404);
}
$contentType = $request->headers->get('Content-Type', 'application/octet-stream');
$object = $this->s3Service->putObject($bucketObj, $key, $request->getContent(), $contentType);
return new Response('', 200, [ return new Response('', 200, [
'ETag' => "\"$etag\"", 'ETag' => '"' . $object->getEtag() . '"',
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
]); ]);
} }
public function getObject(string $bucket, string $key, Request $request): Response public function getObject(string $bucket, string $key, Request $request): Response
{ {
// TODO: Check if object exists $credential = $this->getCredential($request, $bucket, $key);
// TODO: Handle Range requests if (!$credential) {
// TODO: Get object from datastore return new Response('Unauthorized', 401);
}
// For now, return a streamed response
$response = new StreamedResponse(); $bucketObj = $this->s3Service->findBucketByName($bucket);
$response->setCallback(function () use ($bucket, $key) { if (!$bucketObj) {
// TODO: Stream object content from datastore return new Response('', 404);
echo "Object content for $bucket/$key"; }
});
$object = $this->s3Service->findObject($bucketObj, $key);
$response->headers->set('Content-Type', 'application/octet-stream'); if (!$object) {
$response->headers->set('ETag', '"' . md5($bucket . $key) . '"'); return new Response('', 404);
$response->headers->set('x-amz-request-id', uniqid()); }
return $response; $content = $this->s3Service->getObjectContent($object);
return new Response($content, 200, [
'Content-Type' => $object->getContentType(),
'ETag' => '"' . $object->getEtag() . '"',
'x-amz-request-id' => uniqid(),
]);
} }
public function headObject(string $bucket, string $key, Request $request): Response public function headObject(string $bucket, string $key, Request $request): Response
{ {
// TODO: Check if object exists $credential = $this->getCredential($request, $bucket, $key);
// TODO: Get object metadata from datastore if (!$credential) {
return new Response('Unauthorized', 401);
}
$bucketObj = $this->s3Service->findBucketByName($bucket);
if (!$bucketObj) {
return new Response('', 404);
}
$object = $this->s3Service->findObject($bucketObj, $key);
if (!$object) {
return new Response('', 404);
}
return new Response('', 200, [ return new Response('', 200, [
'Content-Type' => 'application/octet-stream', 'Content-Type' => $object->getContentType(),
'Content-Length' => '1024', // TODO: Get actual size 'Content-Length' => $object->getSize(),
'ETag' => '"' . md5($bucket . $key) . '"', 'ETag' => '"' . $object->getEtag() . '"',
'Last-Modified' => gmdate('D, d M Y H:i:s T'), 'Last-Modified' => $object->getUpdatedAt()->format('D, d M Y H:i:s T'),
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
]); ]);
} }
public function deleteObject(string $bucket, string $key, Request $request): Response public function deleteObject(string $bucket, string $key, Request $request): Response
{ {
// TODO: Delete object from datastore $credential = $this->getCredential($request, $bucket, $key);
if (!$credential) {
return new Response('Unauthorized', 401);
}
$bucketObj = $this->s3Service->findBucketByName($bucket);
if (!$bucketObj) {
return new Response('', 404);
}
$object = $this->s3Service->findObject($bucketObj, $key);
if (!$object) {
return new Response('', 404);
}
$this->s3Service->deleteObject($object);
return new Response('', 204, [ return new Response('', 204, [
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
]); ]);
@@ -138,11 +252,20 @@ class S3ApiController extends AbstractController
public function initiateMultipartUpload(string $bucket, string $key, Request $request): Response public function initiateMultipartUpload(string $bucket, string $key, Request $request): Response
{ {
// TODO: Create multipart upload record in datastore $credential = $this->getCredential($request, $bucket, $key);
$uploadId = uniqid('upload_'); if (!$credential) {
return new Response('Unauthorized', 401);
$xml = $this->generateInitiateMultipartXml($bucket, $key, $uploadId); }
$bucketObj = $this->s3Service->findBucketByName($bucket);
if (!$bucketObj) {
return new Response('', 404);
}
$upload = $this->s3Service->initiateMultipartUpload($bucketObj, $key, $credential);
$xml = $this->generateInitiateMultipartXml($bucket, $key, $upload->getUploadId());
return new Response($xml, 200, [ return new Response($xml, 200, [
'Content-Type' => 'application/xml', 'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
@@ -153,14 +276,15 @@ class S3ApiController extends AbstractController
{ {
$partNumber = $request->query->get('partNumber'); $partNumber = $request->query->get('partNumber');
$uploadId = $request->query->get('uploadId'); $uploadId = $request->query->get('uploadId');
$upload = $this->s3Service->findMultipartUpload($uploadId);
// TODO: Validate multipart upload exists if (!$upload) {
// TODO: Store part in datastore return new Response('', 404);
}
$etag = md5($request->getContent());
$part = $this->s3Service->uploadPart($upload, (int) $partNumber, $request->getContent());
return new Response('', 200, [ return new Response('', 200, [
'ETag' => "\"$etag\"", 'ETag' => '"' . $part->getEtag() . '"',
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
]); ]);
} }
@@ -168,13 +292,22 @@ class S3ApiController extends AbstractController
public function completeMultipartUpload(string $bucket, string $key, Request $request): Response public function completeMultipartUpload(string $bucket, string $key, Request $request): Response
{ {
$uploadId = $request->query->get('uploadId'); $uploadId = $request->query->get('uploadId');
$upload = $this->s3Service->findMultipartUpload($uploadId);
// TODO: Parse request XML for part list if (!$upload) {
// TODO: Combine parts into final object return new Response('', 404);
// TODO: Clean up multipart upload record }
$xml = $this->generateCompleteMultipartXml($bucket, $key); $xmlBody = simplexml_load_string($request->getContent());
$parts = [];
if ($xmlBody && isset($xmlBody->Part)) {
foreach ($xmlBody->Part as $p) {
$parts[] = [ 'partNumber' => (int)$p->PartNumber ];
}
}
$object = $this->s3Service->completeMultipartUpload($upload, $parts);
$xml = $this->generateCompleteMultipartXml($bucket, $key, $object->getEtag());
return new Response($xml, 200, [ return new Response($xml, 200, [
'Content-Type' => 'application/xml', 'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
@@ -184,9 +317,13 @@ class S3ApiController extends AbstractController
public function abortMultipartUpload(string $bucket, string $key, Request $request): Response public function abortMultipartUpload(string $bucket, string $key, Request $request): Response
{ {
$uploadId = $request->query->get('uploadId'); $uploadId = $request->query->get('uploadId');
$upload = $this->s3Service->findMultipartUpload($uploadId);
// TODO: Clean up multipart upload and parts if (!$upload) {
return new Response('', 404);
}
$this->s3Service->abortMultipartUpload($upload);
return new Response('', 204, [ return new Response('', 204, [
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
]); ]);
@@ -195,12 +332,23 @@ class S3ApiController extends AbstractController
public function listParts(string $bucket, string $key, Request $request): Response public function listParts(string $bucket, string $key, Request $request): Response
{ {
$uploadId = $request->query->get('uploadId'); $uploadId = $request->query->get('uploadId');
$upload = $this->s3Service->findMultipartUpload($uploadId);
// TODO: Get parts for this multipart upload if (!$upload) {
$parts = []; return new Response('', 404);
}
$xml = $this->generateListPartsXml($bucket, $key, $uploadId, $parts);
$parts = $this->s3Service->listParts($upload);
$items = array_map(function ($p) {
return [
'number' => $p->getPartNumber(),
'etag' => $p->getEtag(),
'size' => $p->getSize(),
'modified' => $p->getUploadedAt()->format('Y-m-d\TH:i:s\.000\Z')
];
}, $parts);
$xml = $this->generateListPartsXml($bucket, $key, $uploadId, $items);
return new Response($xml, 200, [ return new Response($xml, 200, [
'Content-Type' => 'application/xml', 'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
@@ -209,11 +357,27 @@ class S3ApiController extends AbstractController
public function listMultipartUploads(string $bucket, Request $request): Response public function listMultipartUploads(string $bucket, Request $request): Response
{ {
// TODO: Get active multipart uploads for bucket $credential = $this->getCredential($request, $bucket);
$uploads = []; if (!$credential) {
return new Response('Unauthorized', 401);
$xml = $this->generateListMultipartUploadsXml($bucket, $uploads); }
$bucketObj = $this->s3Service->findBucketByName($bucket);
if (!$bucketObj) {
return new Response('', 404);
}
$uploads = $this->s3Service->listMultipartUploads($bucketObj);
$items = array_map(function (S3MultipartUpload $u) {
return [
'key' => $u->getObjectKey(),
'upload_id' => $u->getUploadId(),
'initiated' => $u->getInitiatedAt()->format('Y-m-d\TH:i:s\.000\Z')
];
}, $uploads);
$xml = $this->generateListMultipartUploadsXml($bucket, $items);
return new Response($xml, 200, [ return new Response($xml, 200, [
'Content-Type' => 'application/xml', 'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(), 'x-amz-request-id' => uniqid(),
@@ -278,9 +442,8 @@ class S3ApiController extends AbstractController
return $xml; return $xml;
} }
private function generateCompleteMultipartXml(string $bucket, string $key): string private function generateCompleteMultipartXml(string $bucket, string $key, string $etag): string
{ {
$etag = md5($bucket . $key . time());
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"; $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n"; $xml .= '<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";