Implement basic S3 API operations
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user