From 555b117972d566b50fd7fb77c3afeb3abcdb8156 Mon Sep 17 00:00:00 2001 From: biondizzle <32694450+biondizzle@users.noreply.github.com> Date: Thu, 5 Jun 2025 09:43:12 -0400 Subject: [PATCH] Implement basic S3 API operations --- src/Controller/S3ApiController.php | 341 +++++++++++++++++++++-------- 1 file changed, 252 insertions(+), 89 deletions(-) diff --git a/src/Controller/S3ApiController.php b/src/Controller/S3ApiController.php index 041cd0d..617f195 100644 --- a/src/Controller/S3ApiController.php +++ b/src/Controller/S3ApiController.php @@ -7,18 +7,53 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Routing\Annotation\Route; +use App\Service\S3Service; +use App\Entity\S3Bucket; +use App\Entity\S3MultipartUpload; 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 - + public function listBuckets(Request $request): Response { - // TODO: Implement bucket listing logic - $buckets = []; // Get from your datastore - - $xml = $this->generateListBucketsXml($buckets); - + $credential = $this->getCredential($request); + if (!$credential) { + return new Response('Unauthorized', 401); + } + + $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, [ 'Content-Type' => 'application/xml', 'x-amz-request-id' => uniqid(), @@ -27,10 +62,17 @@ class S3ApiController extends AbstractController public function createBucket(string $bucket, Request $request): Response { - // TODO: Validate bucket name - // TODO: Check if bucket already exists - // TODO: Create bucket in datastore - + $credential = $this->getCredential($request, $bucket); + if (!$credential) { + return new Response('Unauthorized', 401); + } + + if ($this->s3Service->findBucketByName($bucket)) { + return new Response('', 409); + } + + $this->s3Service->createBucket($bucket, $credential); + return new Response('', 200, [ 'Location' => "/$bucket", 'x-amz-request-id' => uniqid(), @@ -39,10 +81,22 @@ class S3ApiController extends AbstractController public function deleteBucket(string $bucket, Request $request): Response { - // TODO: Check if bucket exists - // TODO: Check if bucket is empty - // TODO: Delete bucket from datastore - + $credential = $this->getCredential($request, $bucket); + if (!$credential) { + 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, [ 'x-amz-request-id' => uniqid(), ]); @@ -50,8 +104,11 @@ class S3ApiController extends AbstractController 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, [ 'x-amz-request-id' => uniqid(), ]); @@ -63,12 +120,29 @@ class S3ApiController extends AbstractController $marker = $request->query->get('marker', ''); $maxKeys = (int) $request->query->get('max-keys', 1000); $delimiter = $request->query->get('delimiter'); - - // TODO: Get objects from datastore based on parameters - $objects = []; - - $xml = $this->generateListObjectsXml($bucket, $objects, $prefix, $marker, $maxKeys, $delimiter); - + + $credential = $this->getCredential($request, $bucket); + if (!$credential) { + return new Response('Unauthorized', 401); + } + + $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, [ 'Content-Type' => 'application/xml', 'x-amz-request-id' => uniqid(), @@ -79,56 +153,96 @@ class S3ApiController extends AbstractController public function putObject(string $bucket, string $key, Request $request): Response { - // TODO: Validate bucket exists - // TODO: Get request body/stream - // TODO: Store object in datastore - - $etag = md5($request->getContent()); // Simplified - use actual content hash - + $credential = $this->getCredential($request, $bucket, $key); + if (!$credential) { + return new Response('Unauthorized', 401); + } + + $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, [ - 'ETag' => "\"$etag\"", + 'ETag' => '"' . $object->getEtag() . '"', 'x-amz-request-id' => uniqid(), ]); } public function getObject(string $bucket, string $key, Request $request): Response { - // TODO: Check if object exists - // TODO: Handle Range requests - // TODO: Get object from datastore - - // For now, return a streamed response - $response = new StreamedResponse(); - $response->setCallback(function () use ($bucket, $key) { - // TODO: Stream object content from datastore - echo "Object content for $bucket/$key"; - }); - - $response->headers->set('Content-Type', 'application/octet-stream'); - $response->headers->set('ETag', '"' . md5($bucket . $key) . '"'); - $response->headers->set('x-amz-request-id', uniqid()); - - return $response; + $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); + } + + $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 { - // TODO: Check if object exists - // TODO: Get object metadata 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); + } + return new Response('', 200, [ - 'Content-Type' => 'application/octet-stream', - 'Content-Length' => '1024', // TODO: Get actual size - 'ETag' => '"' . md5($bucket . $key) . '"', - 'Last-Modified' => gmdate('D, d M Y H:i:s T'), + 'Content-Type' => $object->getContentType(), + 'Content-Length' => $object->getSize(), + 'ETag' => '"' . $object->getEtag() . '"', + 'Last-Modified' => $object->getUpdatedAt()->format('D, d M Y H:i:s T'), 'x-amz-request-id' => uniqid(), ]); } 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, [ 'x-amz-request-id' => uniqid(), ]); @@ -138,11 +252,20 @@ class S3ApiController extends AbstractController public function initiateMultipartUpload(string $bucket, string $key, Request $request): Response { - // TODO: Create multipart upload record in datastore - $uploadId = uniqid('upload_'); - - $xml = $this->generateInitiateMultipartXml($bucket, $key, $uploadId); - + $credential = $this->getCredential($request, $bucket, $key); + if (!$credential) { + return new Response('Unauthorized', 401); + } + + $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, [ 'Content-Type' => 'application/xml', 'x-amz-request-id' => uniqid(), @@ -153,14 +276,15 @@ class S3ApiController extends AbstractController { $partNumber = $request->query->get('partNumber'); $uploadId = $request->query->get('uploadId'); - - // TODO: Validate multipart upload exists - // TODO: Store part in datastore - - $etag = md5($request->getContent()); - + $upload = $this->s3Service->findMultipartUpload($uploadId); + if (!$upload) { + return new Response('', 404); + } + + $part = $this->s3Service->uploadPart($upload, (int) $partNumber, $request->getContent()); + return new Response('', 200, [ - 'ETag' => "\"$etag\"", + 'ETag' => '"' . $part->getEtag() . '"', 'x-amz-request-id' => uniqid(), ]); } @@ -168,13 +292,22 @@ class S3ApiController extends AbstractController public function completeMultipartUpload(string $bucket, string $key, Request $request): Response { $uploadId = $request->query->get('uploadId'); - - // TODO: Parse request XML for part list - // TODO: Combine parts into final object - // TODO: Clean up multipart upload record - - $xml = $this->generateCompleteMultipartXml($bucket, $key); - + $upload = $this->s3Service->findMultipartUpload($uploadId); + if (!$upload) { + return new Response('', 404); + } + + $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, [ 'Content-Type' => 'application/xml', 'x-amz-request-id' => uniqid(), @@ -184,9 +317,13 @@ class S3ApiController extends AbstractController public function abortMultipartUpload(string $bucket, string $key, Request $request): Response { $uploadId = $request->query->get('uploadId'); - - // TODO: Clean up multipart upload and parts - + $upload = $this->s3Service->findMultipartUpload($uploadId); + if (!$upload) { + return new Response('', 404); + } + + $this->s3Service->abortMultipartUpload($upload); + return new Response('', 204, [ 'x-amz-request-id' => uniqid(), ]); @@ -195,12 +332,23 @@ class S3ApiController extends AbstractController public function listParts(string $bucket, string $key, Request $request): Response { $uploadId = $request->query->get('uploadId'); - - // TODO: Get parts for this multipart upload - $parts = []; - - $xml = $this->generateListPartsXml($bucket, $key, $uploadId, $parts); - + $upload = $this->s3Service->findMultipartUpload($uploadId); + if (!$upload) { + return new Response('', 404); + } + + $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, [ 'Content-Type' => 'application/xml', 'x-amz-request-id' => uniqid(), @@ -209,11 +357,27 @@ class S3ApiController extends AbstractController public function listMultipartUploads(string $bucket, Request $request): Response { - // TODO: Get active multipart uploads for bucket - $uploads = []; - - $xml = $this->generateListMultipartUploadsXml($bucket, $uploads); - + $credential = $this->getCredential($request, $bucket); + if (!$credential) { + return new Response('Unauthorized', 401); + } + + $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, [ 'Content-Type' => 'application/xml', 'x-amz-request-id' => uniqid(), @@ -278,9 +442,8 @@ class S3ApiController extends AbstractController 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 = '' . "\n"; $xml .= '' . "\n";