init commit

This commit is contained in:
2025-06-05 09:17:47 -04:00
commit db8ec76921
53 changed files with 12126 additions and 0 deletions

0
src/Controller/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,434 @@
<?php
namespace App\Controller;
use App\Entity\S3Credential;
use App\Entity\S3Bucket;
use App\Entity\S3Object;
use App\Service\S3Service;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ConsoleApiController extends AbstractController
{
public function __construct(
private S3Service $s3Service,
private EntityManagerInterface $entityManager
) {}
// Credentials Management
public function credentials(Request $request): JsonResponse
{
if ($request->getMethod() === 'GET') {
$credentials = $this->entityManager->getRepository(S3Credential::class)->findAll();
return new JsonResponse([
'credentials' => array_map(function($cred) {
return [
'id' => $cred->getId(),
'access_key' => $cred->getAccessKey(),
'user_name' => $cred->getUserName(),
'is_active' => $cred->isActive(),
'created_at' => $cred->getCreatedAt()->format('Y-m-d H:i:s'),
'bucket_count' => $cred->getBuckets()->count()
];
}, $credentials)
]);
}
if ($request->getMethod() === 'POST') {
$data = json_decode($request->getContent(), true);
$accessKey = $data['access_key'] ?? 'AKIA' . strtoupper(bin2hex(random_bytes(10)));
$secretKey = $data['secret_key'] ?? base64_encode(random_bytes(30));
$userName = $data['user_name'] ?? null;
$credential = $this->s3Service->createCredential($accessKey, $secretKey, $userName);
return new JsonResponse([
'id' => $credential->getId(),
'access_key' => $credential->getAccessKey(),
'secret_key' => $credential->getSecretKey(),
'user_name' => $credential->getUserName(),
'is_active' => $credential->isActive(),
'created_at' => $credential->getCreatedAt()->format('Y-m-d H:i:s')
], 201);
}
return new JsonResponse(['error' => 'Method not allowed'], 405);
}
public function credentialDetail(int $id, Request $request): JsonResponse
{
$credential = $this->entityManager->getRepository(S3Credential::class)->find($id);
if (!$credential) {
return new JsonResponse(['error' => 'Credential not found'], 404);
}
if ($request->getMethod() === 'GET') {
return new JsonResponse([
'id' => $credential->getId(),
'access_key' => $credential->getAccessKey(),
'secret_key' => $credential->getSecretKey(),
'user_name' => $credential->getUserName(),
'is_active' => $credential->isActive(),
'created_at' => $credential->getCreatedAt()->format('Y-m-d H:i:s'),
'buckets' => array_map(function($bucket) {
return [
'name' => $bucket->getName(),
'region' => $bucket->getRegion(),
'created_at' => $bucket->getCreatedAt()->format('Y-m-d H:i:s')
];
}, $credential->getBuckets()->toArray())
]);
}
if ($request->getMethod() === 'PUT') {
$data = json_decode($request->getContent(), true);
if (isset($data['user_name'])) {
$credential->setUserName($data['user_name']);
}
if (isset($data['is_active'])) {
$credential->setIsActive($data['is_active']);
}
$this->entityManager->flush();
return new JsonResponse(['message' => 'Credential updated']);
}
if ($request->getMethod() === 'DELETE') {
$this->entityManager->remove($credential);
$this->entityManager->flush();
return new JsonResponse(['message' => 'Credential deleted']);
}
return new JsonResponse(['error' => 'Method not allowed'], 405);
}
// Buckets Management
public function buckets(Request $request): JsonResponse
{
if ($request->getMethod() === 'GET') {
$buckets = $this->entityManager->getRepository(S3Bucket::class)->findAll();
return new JsonResponse([
'buckets' => array_map(function($bucket) {
$objectCount = $this->entityManager->getRepository(S3Object::class)->count(['bucket' => $bucket]);
$totalSize = $this->entityManager->createQueryBuilder()
->select('SUM(o.size)')
->from(S3Object::class, 'o')
->where('o.bucket = :bucket')
->setParameter('bucket', $bucket)
->getQuery()
->getSingleScalarResult() ?: 0;
return [
'name' => $bucket->getName(),
'region' => $bucket->getRegion(),
'owner' => $bucket->getOwner()->getUserName() ?: $bucket->getOwner()->getAccessKey(),
'created_at' => $bucket->getCreatedAt()->format('Y-m-d H:i:s'),
'object_count' => $objectCount,
'total_size' => $totalSize,
'total_size_human' => $this->formatBytes($totalSize)
];
}, $buckets)
]);
}
if ($request->getMethod() === 'POST') {
$data = json_decode($request->getContent(), true);
$bucketName = $data['name'] ?? null;
$ownerId = $data['owner_id'] ?? null;
$region = $data['region'] ?? 'us-east-1';
if (!$bucketName || !$ownerId) {
return new JsonResponse(['error' => 'Missing bucket name or owner'], 400);
}
$owner = $this->entityManager->getRepository(S3Credential::class)->find($ownerId);
if (!$owner) {
return new JsonResponse(['error' => 'Owner not found'], 404);
}
try {
$bucket = $this->s3Service->createBucket($bucketName, $owner, $region);
return new JsonResponse([
'name' => $bucket->getName(),
'region' => $bucket->getRegion(),
'owner' => $bucket->getOwner()->getUserName() ?: $bucket->getOwner()->getAccessKey(),
'created_at' => $bucket->getCreatedAt()->format('Y-m-d H:i:s')
], 201);
} catch (\Exception $e) {
return new JsonResponse(['error' => $e->getMessage()], 400);
}
}
return new JsonResponse(['error' => 'Method not allowed'], 405);
}
public function bucketDetail(string $name, Request $request): JsonResponse
{
$bucket = $this->s3Service->findBucketByName($name);
if (!$bucket) {
return new JsonResponse(['error' => 'Bucket not found'], 404);
}
if ($request->getMethod() === 'GET') {
$objects = $this->s3Service->listObjects($bucket);
$totalSize = array_sum(array_map(fn($obj) => $obj->getSize(), $objects));
return new JsonResponse([
'name' => $bucket->getName(),
'region' => $bucket->getRegion(),
'owner' => $bucket->getOwner()->getUserName() ?: $bucket->getOwner()->getAccessKey(),
'created_at' => $bucket->getCreatedAt()->format('Y-m-d H:i:s'),
'object_count' => count($objects),
'total_size' => $totalSize,
'total_size_human' => $this->formatBytes($totalSize),
'objects' => array_map(function($obj) {
return [
'key' => $obj->getObjectKey(),
'size' => $obj->getSize(),
'size_human' => $this->formatBytes($obj->getSize()),
'content_type' => $obj->getContentType(),
'etag' => $obj->getEtag(),
'is_multipart' => $obj->isMultipart(),
'created_at' => $obj->getCreatedAt()->format('Y-m-d H:i:s')
];
}, $objects)
]);
}
if ($request->getMethod() === 'DELETE') {
try {
$this->s3Service->deleteBucket($bucket);
return new JsonResponse(['message' => 'Bucket deleted']);
} catch (\Exception $e) {
return new JsonResponse(['error' => $e->getMessage()], 400);
}
}
return new JsonResponse(['error' => 'Method not allowed'], 405);
}
// Objects Management
public function objects(string $bucketName, Request $request): JsonResponse
{
$bucket = $this->s3Service->findBucketByName($bucketName);
if (!$bucket) {
return new JsonResponse(['error' => 'Bucket not found'], 404);
}
if ($request->getMethod() === 'GET') {
$prefix = $request->query->get('prefix', '');
$objects = $this->s3Service->listObjects($bucket, $prefix);
return new JsonResponse([
'objects' => array_map(function($obj) {
return [
'key' => $obj->getObjectKey(),
'size' => $obj->getSize(),
'size_human' => $this->formatBytes($obj->getSize()),
'content_type' => $obj->getContentType(),
'etag' => $obj->getEtag(),
'is_multipart' => $obj->isMultipart(),
'created_at' => $obj->getCreatedAt()->format('Y-m-d H:i:s')
];
}, $objects)
]);
}
if ($request->getMethod() === 'DELETE') {
$data = json_decode($request->getContent(), true);
$keys = $data['keys'] ?? [];
$deleted = [];
foreach ($keys as $key) {
$object = $this->s3Service->findObject($bucket, $key);
if ($object) {
$this->s3Service->deleteObject($object);
$deleted[] = $key;
}
}
return new JsonResponse(['deleted' => $deleted]);
}
return new JsonResponse(['error' => 'Method not allowed'], 405);
}
public function objectDetail(string $bucketName, string $objectKey, Request $request): JsonResponse
{
$bucket = $this->s3Service->findBucketByName($bucketName);
if (!$bucket) {
return new JsonResponse(['error' => 'Bucket not found'], 404);
}
$object = $this->s3Service->findObject($bucket, $objectKey);
if (!$object) {
return new JsonResponse(['error' => 'Object not found'], 404);
}
if ($request->getMethod() === 'GET') {
return new JsonResponse([
'key' => $object->getObjectKey(),
'size' => $object->getSize(),
'size_human' => $this->formatBytes($object->getSize()),
'content_type' => $object->getContentType(),
'etag' => $object->getEtag(),
'is_multipart' => $object->isMultipart(),
'part_count' => $object->getPartCount(),
'metadata' => $object->getMetadata(),
'storage_path' => $object->getStoragePath(),
'created_at' => $object->getCreatedAt()->format('Y-m-d H:i:s'),
'updated_at' => $object->getUpdatedAt()->format('Y-m-d H:i:s')
]);
}
if ($request->getMethod() === 'DELETE') {
$this->s3Service->deleteObject($object);
return new JsonResponse(['message' => 'Object deleted']);
}
return new JsonResponse(['error' => 'Method not allowed'], 405);
}
// Multipart Uploads
public function multipartUploads(string $bucketName, Request $request): JsonResponse
{
$bucket = $this->s3Service->findBucketByName($bucketName);
if (!$bucket) {
return new JsonResponse(['error' => 'Bucket not found'], 404);
}
$uploads = $this->s3Service->listMultipartUploads($bucket);
return new JsonResponse([
'uploads' => array_map(function($upload) {
$parts = $this->s3Service->listParts($upload);
$totalSize = array_sum(array_map(fn($part) => $part->getSize(), $parts));
return [
'upload_id' => $upload->getUploadId(),
'object_key' => $upload->getObjectKey(),
'initiated_by' => $upload->getInitiatedBy()->getUserName() ?: $upload->getInitiatedBy()->getAccessKey(),
'content_type' => $upload->getContentType(),
'initiated_at' => $upload->getInitiatedAt()->format('Y-m-d H:i:s'),
'part_count' => count($parts),
'total_size' => $totalSize,
'total_size_human' => $this->formatBytes($totalSize)
];
}, $uploads)
]);
}
// Presigned URLs
public function presignedUrls(Request $request): JsonResponse
{
if ($request->getMethod() === 'GET') {
$urls = $this->entityManager->getRepository(\App\Entity\S3PresignedUrl::class)
->createQueryBuilder('p')
->where('p.expiresAt > :now')
->setParameter('now', new \DateTime())
->orderBy('p.createdAt', 'DESC')
->setMaxResults(100)
->getQuery()
->getResult();
return new JsonResponse([
'urls' => array_map(function($url) {
return [
'bucket_name' => $url->getBucketName(),
'object_key' => $url->getObjectKey(),
'method' => $url->getMethod(),
'access_key' => $url->getAccessKey(),
'expires_at' => $url->getExpiresAt()->format('Y-m-d H:i:s'),
'created_at' => $url->getCreatedAt()->format('Y-m-d H:i:s')
];
}, $urls)
]);
}
if ($request->getMethod() === 'POST') {
$data = json_decode($request->getContent(), true);
$bucketName = $data['bucket_name'] ?? null;
$objectKey = $data['object_key'] ?? null;
$method = $data['method'] ?? 'GET';
$expiresIn = $data['expires_in'] ?? 3600;
$accessKey = $data['access_key'] ?? null;
if (!$bucketName || !$objectKey || !$accessKey) {
return new JsonResponse(['error' => 'Missing required fields'], 400);
}
$credential = $this->s3Service->findCredentialByAccessKey($accessKey);
if (!$credential) {
return new JsonResponse(['error' => 'Invalid access key'], 404);
}
$url = $this->s3Service->generatePresignedGetUrl($bucketName, $objectKey, $credential, $expiresIn);
return new JsonResponse(['url' => $url], 201);
}
return new JsonResponse(['error' => 'Method not allowed'], 405);
}
// Statistics
public function stats(Request $request): JsonResponse
{
$credentialCount = $this->entityManager->getRepository(S3Credential::class)->count([]);
$bucketCount = $this->entityManager->getRepository(S3Bucket::class)->count([]);
$objectCount = $this->entityManager->getRepository(S3Object::class)->count([]);
$totalSize = $this->entityManager->createQueryBuilder()
->select('SUM(o.size)')
->from(S3Object::class, 'o')
->getQuery()
->getSingleScalarResult() ?: 0;
$multipartUploads = $this->entityManager->getRepository(\App\Entity\S3MultipartUpload::class)->count([]);
$activePresignedUrls = $this->entityManager->getRepository(\App\Entity\S3PresignedUrl::class)
->createQueryBuilder('p')
->select('COUNT(p.id)')
->where('p.expiresAt > :now')
->setParameter('now', new \DateTime())
->getQuery()
->getSingleScalarResult();
return new JsonResponse([
'credentials' => $credentialCount,
'buckets' => $bucketCount,
'objects' => $objectCount,
'total_storage' => $totalSize,
'total_storage_human' => $this->formatBytes($totalSize),
'active_multipart_uploads' => $multipartUploads,
'active_presigned_urls' => $activePresignedUrls
]);
}
private function formatBytes(int $bytes): string
{
if ($bytes === 0) return '0 B';
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = floor(log($bytes, 1024));
return round($bytes / pow(1024, $i), 2) . ' ' . $units[$i];
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
class ConsoleController extends AbstractController
{
public function index(string $route = ''): Response
{
return $this->render('console/index.html.twig');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class DocsController extends AbstractController
{
#[Route('/docs', name: 'docs_show', priority: 0)]
public function show(): Response
{
// the template path is the relative file path from `templates/`
return $this->render('openapi/html/index.html', [ ]);
}
}

View File

@@ -0,0 +1,348 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Annotation\Route;
class S3ApiController extends AbstractController
{
// Bucket Operations
public function listBuckets(Request $request): Response
{
// TODO: Implement bucket listing logic
$buckets = []; // Get from your datastore
$xml = $this->generateListBucketsXml($buckets);
return new Response($xml, 200, [
'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(),
]);
}
public function createBucket(string $bucket, Request $request): Response
{
// TODO: Validate bucket name
// TODO: Check if bucket already exists
// TODO: Create bucket in datastore
return new Response('', 200, [
'Location' => "/$bucket",
'x-amz-request-id' => uniqid(),
]);
}
public function deleteBucket(string $bucket, Request $request): Response
{
// TODO: Check if bucket exists
// TODO: Check if bucket is empty
// TODO: Delete bucket from datastore
return new Response('', 204, [
'x-amz-request-id' => uniqid(),
]);
}
public function headBucket(string $bucket, Request $request): Response
{
// TODO: Check if bucket exists
return new Response('', 200, [
'x-amz-request-id' => uniqid(),
]);
}
public function listObjects(string $bucket, Request $request): Response
{
$prefix = $request->query->get('prefix', '');
$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);
return new Response($xml, 200, [
'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(),
]);
}
// Object Operations
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
return new Response('', 200, [
'ETag' => "\"$etag\"",
'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;
}
public function headObject(string $bucket, string $key, Request $request): Response
{
// TODO: Check if object exists
// TODO: Get object metadata from datastore
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'),
'x-amz-request-id' => uniqid(),
]);
}
public function deleteObject(string $bucket, string $key, Request $request): Response
{
// TODO: Delete object from datastore
return new Response('', 204, [
'x-amz-request-id' => uniqid(),
]);
}
// Multipart Upload Operations
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);
return new Response($xml, 200, [
'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(),
]);
}
public function uploadPart(string $bucket, string $key, Request $request): Response
{
$partNumber = $request->query->get('partNumber');
$uploadId = $request->query->get('uploadId');
// TODO: Validate multipart upload exists
// TODO: Store part in datastore
$etag = md5($request->getContent());
return new Response('', 200, [
'ETag' => "\"$etag\"",
'x-amz-request-id' => uniqid(),
]);
}
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);
return new Response($xml, 200, [
'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(),
]);
}
public function abortMultipartUpload(string $bucket, string $key, Request $request): Response
{
$uploadId = $request->query->get('uploadId');
// TODO: Clean up multipart upload and parts
return new Response('', 204, [
'x-amz-request-id' => uniqid(),
]);
}
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);
return new Response($xml, 200, [
'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(),
]);
}
public function listMultipartUploads(string $bucket, Request $request): Response
{
// TODO: Get active multipart uploads for bucket
$uploads = [];
$xml = $this->generateListMultipartUploadsXml($bucket, $uploads);
return new Response($xml, 200, [
'Content-Type' => 'application/xml',
'x-amz-request-id' => uniqid(),
]);
}
// XML Response Generators
private function generateListBucketsXml(array $buckets): string
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
$xml .= '<Buckets>' . "\n";
foreach ($buckets as $bucket) {
$xml .= '<Bucket>' . "\n";
$xml .= '<Name>' . htmlspecialchars($bucket['name']) . '</Name>' . "\n";
$xml .= '<CreationDate>' . $bucket['created'] . '</CreationDate>' . "\n";
$xml .= '</Bucket>' . "\n";
}
$xml .= '</Buckets>' . "\n";
$xml .= '</ListAllMyBucketsResult>' . "\n";
return $xml;
}
private function generateListObjectsXml(string $bucket, array $objects, string $prefix, string $marker, int $maxKeys, ?string $delimiter): string
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
$xml .= '<Name>' . htmlspecialchars($bucket) . '</Name>' . "\n";
$xml .= '<Prefix>' . htmlspecialchars($prefix) . '</Prefix>' . "\n";
$xml .= '<Marker>' . htmlspecialchars($marker) . '</Marker>' . "\n";
$xml .= '<MaxKeys>' . $maxKeys . '</MaxKeys>' . "\n";
$xml .= '<IsTruncated>false</IsTruncated>' . "\n";
foreach ($objects as $object) {
$xml .= '<Contents>' . "\n";
$xml .= '<Key>' . htmlspecialchars($object['key']) . '</Key>' . "\n";
$xml .= '<LastModified>' . $object['modified'] . '</LastModified>' . "\n";
$xml .= '<ETag>"' . $object['etag'] . '"</ETag>' . "\n";
$xml .= '<Size>' . $object['size'] . '</Size>' . "\n";
$xml .= '<StorageClass>STANDARD</StorageClass>' . "\n";
$xml .= '</Contents>' . "\n";
}
$xml .= '</ListBucketResult>' . "\n";
return $xml;
}
private function generateInitiateMultipartXml(string $bucket, string $key, string $uploadId): string
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
$xml .= '<Bucket>' . htmlspecialchars($bucket) . '</Bucket>' . "\n";
$xml .= '<Key>' . htmlspecialchars($key) . '</Key>' . "\n";
$xml .= '<UploadId>' . htmlspecialchars($uploadId) . '</UploadId>' . "\n";
$xml .= '</InitiateMultipartUploadResult>' . "\n";
return $xml;
}
private function generateCompleteMultipartXml(string $bucket, string $key): string
{
$etag = md5($bucket . $key . time());
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
$xml .= '<Location>/' . htmlspecialchars($bucket) . '/' . htmlspecialchars($key) . '</Location>' . "\n";
$xml .= '<Bucket>' . htmlspecialchars($bucket) . '</Bucket>' . "\n";
$xml .= '<Key>' . htmlspecialchars($key) . '</Key>' . "\n";
$xml .= '<ETag>"' . $etag . '"</ETag>' . "\n";
$xml .= '</CompleteMultipartUploadResult>' . "\n";
return $xml;
}
private function generateListPartsXml(string $bucket, string $key, string $uploadId, array $parts): string
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
$xml .= '<Bucket>' . htmlspecialchars($bucket) . '</Bucket>' . "\n";
$xml .= '<Key>' . htmlspecialchars($key) . '</Key>' . "\n";
$xml .= '<UploadId>' . htmlspecialchars($uploadId) . '</UploadId>' . "\n";
$xml .= '<StorageClass>STANDARD</StorageClass>' . "\n";
$xml .= '<PartNumberMarker>0</PartNumberMarker>' . "\n";
$xml .= '<NextPartNumberMarker>0</NextPartNumberMarker>' . "\n";
$xml .= '<MaxParts>1000</MaxParts>' . "\n";
$xml .= '<IsTruncated>false</IsTruncated>' . "\n";
foreach ($parts as $part) {
$xml .= '<Part>' . "\n";
$xml .= '<PartNumber>' . $part['number'] . '</PartNumber>' . "\n";
$xml .= '<LastModified>' . $part['modified'] . '</LastModified>' . "\n";
$xml .= '<ETag>"' . $part['etag'] . '"</ETag>' . "\n";
$xml .= '<Size>' . $part['size'] . '</Size>' . "\n";
$xml .= '</Part>' . "\n";
}
$xml .= '</ListPartsResult>' . "\n";
return $xml;
}
private function generateListMultipartUploadsXml(string $bucket, array $uploads): string
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
$xml .= '<Bucket>' . htmlspecialchars($bucket) . '</Bucket>' . "\n";
$xml .= '<KeyMarker></KeyMarker>' . "\n";
$xml .= '<UploadIdMarker></UploadIdMarker>' . "\n";
$xml .= '<NextKeyMarker></NextKeyMarker>' . "\n";
$xml .= '<NextUploadIdMarker></NextUploadIdMarker>' . "\n";
$xml .= '<MaxUploads>1000</MaxUploads>' . "\n";
$xml .= '<IsTruncated>false</IsTruncated>' . "\n";
foreach ($uploads as $upload) {
$xml .= '<Upload>' . "\n";
$xml .= '<Key>' . htmlspecialchars($upload['key']) . '</Key>' . "\n";
$xml .= '<UploadId>' . htmlspecialchars($upload['upload_id']) . '</UploadId>' . "\n";
$xml .= '<Initiated>' . $upload['initiated'] . '</Initiated>' . "\n";
$xml .= '<StorageClass>STANDARD</StorageClass>' . "\n";
$xml .= '</Upload>' . "\n";
}
$xml .= '</ListMultipartUploadsResult>' . "\n";
return $xml;
}
}

0
src/Entity/.gitignore vendored Normal file
View File

57
src/Entity/S3Bucket.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
#[ORM\Table(name: 's3_buckets')]
#[ORM\Index(columns: ['name'], name: 'idx_name')]
#[ORM\Index(columns: ['owner_id'], name: 'idx_owner')]
class S3Bucket
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;
#[ORM\Column(type: 'string', length: 63, unique: true)]
private string $name;
#[ORM\ManyToOne(targetEntity: S3Credential::class, inversedBy: 'buckets')]
#[ORM\JoinColumn(nullable: false)]
private S3Credential $owner;
#[ORM\Column(type: 'string', length: 32)]
private string $region = 'us-east-1';
#[ORM\Column(type: 'datetime')]
private \DateTime $createdAt;
#[ORM\Column(type: 'datetime')]
private \DateTime $updatedAt;
#[ORM\OneToMany(mappedBy: 'bucket', targetEntity: S3Object::class)]
private Collection $objects;
public function __construct()
{
$this->objects = new ArrayCollection();
$this->createdAt = new \DateTime();
$this->updatedAt = new \DateTime();
}
public function getId(): int { return $this->id; }
public function getName(): string { return $this->name; }
public function setName(string $name): self { $this->name = $name; return $this; }
public function getOwner(): S3Credential { return $this->owner; }
public function setOwner(S3Credential $owner): self { $this->owner = $owner; return $this; }
public function getRegion(): string { return $this->region; }
public function setRegion(string $region): self { $this->region = $region; return $this; }
public function getCreatedAt(): \DateTime { return $this->createdAt; }
public function getUpdatedAt(): \DateTime { return $this->updatedAt; }
public function setUpdatedAt(\DateTime $updatedAt): self { $this->updatedAt = $updatedAt; return $this; }
public function getObjects(): Collection { return $this->objects; }
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
#[ORM\Table(name: 's3_credentials')]
#[ORM\Index(columns: ['access_key'], name: 'idx_access_key')]
#[ORM\Index(columns: ['is_active'], name: 'idx_active')]
class S3Credential
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;
#[ORM\Column(type: 'string', length: 32, unique: true)]
private string $accessKey;
#[ORM\Column(type: 'string', length: 64)]
private string $secretKey;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $userName = null;
#[ORM\Column(type: 'boolean')]
private bool $isActive = true;
#[ORM\Column(type: 'datetime')]
private \DateTime $createdAt;
#[ORM\Column(type: 'datetime')]
private \DateTime $updatedAt;
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: S3Bucket::class)]
private Collection $buckets;
public function __construct()
{
$this->buckets = new ArrayCollection();
$this->createdAt = new \DateTime();
$this->updatedAt = new \DateTime();
}
public function getId(): int { return $this->id; }
public function getAccessKey(): string { return $this->accessKey; }
public function setAccessKey(string $accessKey): self { $this->accessKey = $accessKey; return $this; }
public function getSecretKey(): string { return $this->secretKey; }
public function setSecretKey(string $secretKey): self { $this->secretKey = $secretKey; return $this; }
public function getUserName(): ?string { return $this->userName; }
public function setUserName(?string $userName): self { $this->userName = $userName; return $this; }
public function isActive(): bool { return $this->isActive; }
public function setIsActive(bool $isActive): self { $this->isActive = $isActive; return $this; }
public function getCreatedAt(): \DateTime { return $this->createdAt; }
public function getUpdatedAt(): \DateTime { return $this->updatedAt; }
public function setUpdatedAt(\DateTime $updatedAt): self { $this->updatedAt = $updatedAt; return $this; }
public function getBuckets(): Collection { return $this->buckets; }
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
#[ORM\Table(name: 's3_multipart_parts')]
#[ORM\UniqueConstraint(name: 'uk_upload_part', columns: ['upload_id', 'part_number'])]
#[ORM\Index(columns: ['upload_id'], name: 'idx_upload_id')]
#[ORM\Index(columns: ['part_number'], name: 'idx_part_number')]
class S3MultipartPart
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'bigint')]
private int $id;
#[ORM\ManyToOne(targetEntity: S3MultipartUpload::class, inversedBy: 'parts')]
#[ORM\JoinColumn(referencedColumnName: 'upload_id')]
private S3MultipartUpload $upload;
#[ORM\Column(type: 'integer')]
private int $partNumber;
#[ORM\Column(type: 'bigint')]
private int $size;
#[ORM\Column(type: 'string', length: 64)]
private string $etag;
#[ORM\Column(type: 'string', length: 2048)]
private string $storagePath;
#[ORM\Column(type: 'datetime')]
private \DateTime $uploadedAt;
public function __construct()
{
$this->uploadedAt = new \DateTime();
}
public function getId(): int { return $this->id; }
public function getUpload(): S3MultipartUpload { return $this->upload; }
public function setUpload(S3MultipartUpload $upload): self { $this->upload = $upload; return $this; }
public function getPartNumber(): int { return $this->partNumber; }
public function setPartNumber(int $partNumber): self { $this->partNumber = $partNumber; return $this; }
public function getSize(): int { return $this->size; }
public function setSize(int $size): self { $this->size = $size; return $this; }
public function getEtag(): string { return $this->etag; }
public function setEtag(string $etag): self { $this->etag = $etag; return $this; }
public function getStoragePath(): string { return $this->storagePath; }
public function setStoragePath(string $storagePath): self { $this->storagePath = $storagePath; return $this; }
public function getUploadedAt(): \DateTime { return $this->uploadedAt; }
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
#[ORM\Table(name: 's3_multipart_uploads')]
#[ORM\Index(columns: ['upload_id'], name: 'idx_upload_id')]
#[ORM\Index(columns: ['bucket_id', 'object_key'], name: 'idx_bucket_key')]
#[ORM\Index(columns: ['expires_at'], name: 'idx_expires')]
class S3MultipartUpload
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'bigint')]
private int $id;
#[ORM\Column(type: 'string', length: 64, unique: true)]
private string $uploadId;
#[ORM\ManyToOne(targetEntity: S3Bucket::class)]
#[ORM\JoinColumn(nullable: false)]
private S3Bucket $bucket;
#[ORM\Column(type: 'string', length: 255)]
private string $objectKey;
#[ORM\ManyToOne(targetEntity: S3Credential::class)]
#[ORM\JoinColumn(nullable: false)]
private S3Credential $initiatedBy;
#[ORM\Column(type: 'string', length: 2048)]
private string $storagePathPrefix;
#[ORM\Column(type: 'string', length: 255)]
private string $contentType = 'application/octet-stream';
#[ORM\Column(type: 'json', nullable: true)]
private ?array $metadata = null;
#[ORM\Column(type: 'datetime')]
private \DateTime $initiatedAt;
#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTime $expiresAt = null;
#[ORM\OneToMany(mappedBy: 'upload', targetEntity: S3MultipartPart::class)]
private Collection $parts;
public function __construct()
{
$this->parts = new ArrayCollection();
$this->initiatedAt = new \DateTime();
$this->uploadId = uniqid('upload_') . '_' . bin2hex(random_bytes(8));
}
public function getId(): int { return $this->id; }
public function getUploadId(): string { return $this->uploadId; }
public function setUploadId(string $uploadId): self { $this->uploadId = $uploadId; return $this; }
public function getBucket(): S3Bucket { return $this->bucket; }
public function setBucket(S3Bucket $bucket): self { $this->bucket = $bucket; return $this; }
public function getObjectKey(): string { return $this->objectKey; }
public function setObjectKey(string $objectKey): self { $this->objectKey = $objectKey; return $this; }
public function getInitiatedBy(): S3Credential { return $this->initiatedBy; }
public function setInitiatedBy(S3Credential $initiatedBy): self { $this->initiatedBy = $initiatedBy; return $this; }
public function getStoragePathPrefix(): string { return $this->storagePathPrefix; }
public function setStoragePathPrefix(string $storagePathPrefix): self { $this->storagePathPrefix = $storagePathPrefix; return $this; }
public function getContentType(): string { return $this->contentType; }
public function setContentType(string $contentType): self { $this->contentType = $contentType; return $this; }
public function getMetadata(): ?array { return $this->metadata; }
public function setMetadata(?array $metadata): self { $this->metadata = $metadata; return $this; }
public function getInitiatedAt(): \DateTime { return $this->initiatedAt; }
public function getExpiresAt(): ?\DateTime { return $this->expiresAt; }
public function setExpiresAt(?\DateTime $expiresAt): self { $this->expiresAt = $expiresAt; return $this; }
public function getParts(): Collection { return $this->parts; }
}

85
src/Entity/S3Object.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
#[ORM\Table(name: 's3_objects')]
#[ORM\UniqueConstraint(name: 'uk_bucket_object', columns: ['bucket_id', 'object_key'])]
#[ORM\Index(columns: ['bucket_id', 'object_key'], name: 'idx_bucket_key')]
#[ORM\Index(columns: ['etag'], name: 'idx_etag')]
#[ORM\Index(columns: ['size'], name: 'idx_size')]
#[ORM\Index(columns: ['created_at'], name: 'idx_created')]
class S3Object
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'bigint')]
private int $id;
#[ORM\ManyToOne(targetEntity: S3Bucket::class, inversedBy: 'objects')]
#[ORM\JoinColumn(nullable: false)]
private S3Bucket $bucket;
#[ORM\Column(type: 'string', length: 255)]
private string $objectKey;
#[ORM\Column(type: 'bigint')]
private int $size = 0;
#[ORM\Column(type: 'string', length: 64)]
private string $etag;
#[ORM\Column(type: 'string', length: 255)]
private string $contentType = 'application/octet-stream';
#[ORM\Column(type: 'string', length: 2048)]
private string $storagePath;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $metadata = null;
#[ORM\Column(type: 'boolean')]
private bool $isMultipart = false;
#[ORM\Column(type: 'integer')]
private int $partCount = 0;
#[ORM\Column(type: 'datetime')]
private \DateTime $createdAt;
#[ORM\Column(type: 'datetime')]
private \DateTime $updatedAt;
public function __construct()
{
$this->createdAt = new \DateTime();
$this->updatedAt = new \DateTime();
}
public function getId(): int { return $this->id; }
public function getBucket(): S3Bucket { return $this->bucket; }
public function setBucket(S3Bucket $bucket): self { $this->bucket = $bucket; return $this; }
public function getObjectKey(): string { return $this->objectKey; }
public function setObjectKey(string $objectKey): self { $this->objectKey = $objectKey; return $this; }
public function getSize(): int { return $this->size; }
public function setSize(int $size): self { $this->size = $size; return $this; }
public function getEtag(): string { return $this->etag; }
public function setEtag(string $etag): self { $this->etag = $etag; return $this; }
public function getContentType(): string { return $this->contentType; }
public function setContentType(string $contentType): self { $this->contentType = $contentType; return $this; }
public function getStoragePath(): string { return $this->storagePath; }
public function setStoragePath(string $storagePath): self { $this->storagePath = $storagePath; return $this; }
public function getMetadata(): ?array { return $this->metadata; }
public function setMetadata(?array $metadata): self { $this->metadata = $metadata; return $this; }
public function isMultipart(): bool { return $this->isMultipart; }
public function setIsMultipart(bool $isMultipart): self { $this->isMultipart = $isMultipart; return $this; }
public function getPartCount(): int { return $this->partCount; }
public function setPartCount(int $partCount): self { $this->partCount = $partCount; return $this; }
public function getCreatedAt(): \DateTime { return $this->createdAt; }
public function getUpdatedAt(): \DateTime { return $this->updatedAt; }
public function setUpdatedAt(\DateTime $updatedAt): self { $this->updatedAt = $updatedAt; return $this; }
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
#[ORM\Table(name: 's3_presigned_urls')]
#[ORM\Index(columns: ['url_hash'], name: 'idx_url_hash')]
#[ORM\Index(columns: ['expires_at'], name: 'idx_expires')]
#[ORM\Index(columns: ['bucket_name', 'object_key'], name: 'idx_bucket_key')]
#[ORM\Index(columns: ['access_key'], name: 'idx_access_key')]
class S3PresignedUrl
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'bigint')]
private int $id;
#[ORM\Column(type: 'string', length: 64, unique: true)]
private string $urlHash;
#[ORM\Column(type: 'string', length: 63)]
private string $bucketName;
#[ORM\Column(type: 'string', length: 255)]
private string $objectKey;
#[ORM\Column(type: 'string', length: 10)]
private string $method;
#[ORM\Column(type: 'datetime')]
private \DateTime $expiresAt;
#[ORM\Column(type: 'string', length: 32)]
private string $accessKey;
#[ORM\Column(type: 'datetime')]
private \DateTime $createdAt;
public function __construct()
{
$this->createdAt = new \DateTime();
}
public function getId(): int { return $this->id; }
public function getUrlHash(): string { return $this->urlHash; }
public function setUrlHash(string $urlHash): self { $this->urlHash = $urlHash; return $this; }
public function getBucketName(): string { return $this->bucketName; }
public function setBucketName(string $bucketName): self { $this->bucketName = $bucketName; return $this; }
public function getObjectKey(): string { return $this->objectKey; }
public function setObjectKey(string $objectKey): self { $this->objectKey = $objectKey; return $this; }
public function getMethod(): string { return $this->method; }
public function setMethod(string $method): self { $this->method = $method; return $this; }
public function getExpiresAt(): \DateTime { return $this->expiresAt; }
public function setExpiresAt(\DateTime $expiresAt): self { $this->expiresAt = $expiresAt; return $this; }
public function getAccessKey(): string { return $this->accessKey; }
public function setAccessKey(string $accessKey): self { $this->accessKey = $accessKey; return $this; }
public function getCreatedAt(): \DateTime { return $this->createdAt; }
public function isExpired(): bool
{
return $this->expiresAt < new \DateTime();
}
}

11
src/Kernel.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

0
src/Repository/.gitignore vendored Normal file
View File

430
src/Service/S3Service.php Normal file
View File

@@ -0,0 +1,430 @@
<?php
// src/Service/S3Service.php
namespace App\Service;
use App\Entity\S3Bucket;
use App\Entity\S3Credential;
use App\Entity\S3Object;
use App\Entity\S3MultipartUpload;
use App\Entity\S3MultipartPart;
use App\Entity\S3PresignedUrl;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class S3Service
{
public function __construct(
private EntityManagerInterface $entityManager,
private string $storageBasePath = '/var/s3storage'
) {}
// Credential Management
public function findCredentialByAccessKey(string $accessKey): ?S3Credential
{
return $this->entityManager->getRepository(S3Credential::class)
->findOneBy(['accessKey' => $accessKey, 'isActive' => true]);
}
public function createCredential(string $accessKey, string $secretKey, ?string $userName = null): S3Credential
{
$credential = new S3Credential();
$credential->setAccessKey($accessKey)
->setSecretKey($secretKey)
->setUserName($userName);
$this->entityManager->persist($credential);
$this->entityManager->flush();
return $credential;
}
// Bucket Management
public function findBucketByName(string $name): ?S3Bucket
{
return $this->entityManager->getRepository(S3Bucket::class)
->findOneBy(['name' => $name]);
}
public function createBucket(string $name, S3Credential $owner, string $region = 'us-east-1'): S3Bucket
{
$bucket = new S3Bucket();
$bucket->setName($name)
->setOwner($owner)
->setRegion($region);
$this->entityManager->persist($bucket);
$this->entityManager->flush();
// Create bucket directory
$bucketPath = $this->storageBasePath . '/' . $name;
if (!is_dir($bucketPath)) {
mkdir($bucketPath, 0755, true);
}
return $bucket;
}
public function deleteBucket(S3Bucket $bucket): bool
{
// Check if bucket is empty
$objectCount = $this->entityManager->getRepository(S3Object::class)
->count(['bucket' => $bucket]);
if ($objectCount > 0) {
throw new \Exception('Bucket not empty');
}
$this->entityManager->remove($bucket);
$this->entityManager->flush();
// Remove bucket directory if empty
$bucketPath = $this->storageBasePath . '/' . $bucket->getName();
if (is_dir($bucketPath) && count(scandir($bucketPath)) === 2) { // Only . and ..
rmdir($bucketPath);
}
return true;
}
public function listBuckets(S3Credential $owner): array
{
return $this->entityManager->getRepository(S3Bucket::class)
->findBy(['owner' => $owner], ['createdAt' => 'ASC']);
}
// Object Management
public function findObject(S3Bucket $bucket, string $objectKey): ?S3Object
{
return $this->entityManager->getRepository(S3Object::class)
->findOneBy(['bucket' => $bucket, 'objectKey' => $objectKey]);
}
public function putObject(S3Bucket $bucket, string $objectKey, string $content, string $contentType = 'application/octet-stream', array $metadata = []): S3Object
{
// Generate storage path
$storagePath = $this->generateStoragePath($bucket->getName(), $objectKey);
$fullPath = $this->storageBasePath . '/' . $storagePath;
// Ensure directory exists
$dir = dirname($fullPath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
// Write file
file_put_contents($fullPath, $content);
// Calculate ETag
$etag = md5($content);
$size = strlen($content);
// Create or update object record
$object = $this->findObject($bucket, $objectKey);
if (!$object) {
$object = new S3Object();
$object->setBucket($bucket)->setObjectKey($objectKey);
}
$object->setSize($size)
->setEtag($etag)
->setContentType($contentType)
->setStoragePath($storagePath)
->setMetadata($metadata)
->setUpdatedAt(new \DateTime());
$this->entityManager->persist($object);
$this->entityManager->flush();
return $object;
}
public function getObjectContent(S3Object $object): string
{
$fullPath = $this->storageBasePath . '/' . $object->getStoragePath();
if (!file_exists($fullPath)) {
throw new \Exception('Object file not found');
}
return file_get_contents($fullPath);
}
public function deleteObject(S3Object $object): bool
{
$fullPath = $this->storageBasePath . '/' . $object->getStoragePath();
// Delete file
if (file_exists($fullPath)) {
unlink($fullPath);
}
// Remove database record
$this->entityManager->remove($object);
$this->entityManager->flush();
return true;
}
public function listObjects(S3Bucket $bucket, string $prefix = '', string $marker = '', int $maxKeys = 1000, ?string $delimiter = null): array
{
$qb = $this->entityManager->createQueryBuilder();
$qb->select('o')
->from(S3Object::class, 'o')
->where('o.bucket = :bucket')
->setParameter('bucket', $bucket)
->orderBy('o.objectKey', 'ASC')
->setMaxResults($maxKeys);
if ($prefix) {
$qb->andWhere('o.objectKey LIKE :prefix')
->setParameter('prefix', $prefix . '%');
}
if ($marker) {
$qb->andWhere('o.objectKey > :marker')
->setParameter('marker', $marker);
}
return $qb->getQuery()->getResult();
}
// Multipart Upload Management
public function initiateMultipartUpload(S3Bucket $bucket, string $objectKey, S3Credential $initiatedBy, string $contentType = 'application/octet-stream', array $metadata = []): S3MultipartUpload
{
$upload = new S3MultipartUpload();
$upload->setBucket($bucket)
->setObjectKey($objectKey)
->setInitiatedBy($initiatedBy)
->setContentType($contentType)
->setMetadata($metadata)
->setStoragePathPrefix($this->generateMultipartPrefix($bucket->getName(), $objectKey, $upload->getUploadId()));
$this->entityManager->persist($upload);
$this->entityManager->flush();
// Create multipart directory
$multipartDir = $this->storageBasePath . '/' . $upload->getStoragePathPrefix();
if (!is_dir($multipartDir)) {
mkdir($multipartDir, 0755, true);
}
return $upload;
}
public function findMultipartUpload(string $uploadId): ?S3MultipartUpload
{
return $this->entityManager->getRepository(S3MultipartUpload::class)
->findOneBy(['uploadId' => $uploadId]);
}
public function uploadPart(S3MultipartUpload $upload, int $partNumber, string $content): S3MultipartPart
{
// Generate part storage path
$partPath = $upload->getStoragePathPrefix() . '/part_' . str_pad($partNumber, 5, '0', STR_PAD_LEFT);
$fullPath = $this->storageBasePath . '/' . $partPath;
// Write part file
file_put_contents($fullPath, $content);
// Calculate part ETag
$etag = md5($content);
$size = strlen($content);
// Create or update part record
$part = $this->entityManager->getRepository(S3MultipartPart::class)
->findOneBy(['upload' => $upload, 'partNumber' => $partNumber]);
if (!$part) {
$part = new S3MultipartPart();
$part->setUpload($upload)->setPartNumber($partNumber);
}
$part->setSize($size)
->setEtag($etag)
->setStoragePath($partPath);
$this->entityManager->persist($part);
$this->entityManager->flush();
return $part;
}
public function completeMultipartUpload(S3MultipartUpload $upload, array $parts): S3Object
{
// Validate all parts exist
$existingParts = $this->entityManager->getRepository(S3MultipartPart::class)
->findBy(['upload' => $upload], ['partNumber' => 'ASC']);
if (count($existingParts) !== count($parts)) {
throw new \Exception('Missing parts for multipart upload');
}
// Combine parts into final object
$finalPath = $this->generateStoragePath($upload->getBucket()->getName(), $upload->getObjectKey());
$fullFinalPath = $this->storageBasePath . '/' . $finalPath;
// Ensure directory exists
$dir = dirname($fullFinalPath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
// Combine parts
$finalFile = fopen($fullFinalPath, 'wb');
$totalSize = 0;
$combinedEtag = '';
foreach ($existingParts as $part) {
$partPath = $this->storageBasePath . '/' . $part->getStoragePath();
$partContent = file_get_contents($partPath);
fwrite($finalFile, $partContent);
$totalSize += $part->getSize();
$combinedEtag .= $part->getEtag();
}
fclose($finalFile);
// Calculate final ETag (MD5 of concatenated part ETags + part count)
$finalEtag = md5($combinedEtag) . '-' . count($existingParts);
// Create final object record
$object = new S3Object();
$object->setBucket($upload->getBucket())
->setObjectKey($upload->getObjectKey())
->setSize($totalSize)
->setEtag($finalEtag)
->setContentType($upload->getContentType())
->setStoragePath($finalPath)
->setMetadata($upload->getMetadata())
->setIsMultipart(true)
->setPartCount(count($existingParts));
$this->entityManager->persist($object);
// Clean up multipart upload and parts
$this->abortMultipartUpload($upload);
$this->entityManager->flush();
return $object;
}
public function abortMultipartUpload(S3MultipartUpload $upload): bool
{
// Delete all part files
$parts = $this->entityManager->getRepository(S3MultipartPart::class)
->findBy(['upload' => $upload]);
foreach ($parts as $part) {
$partPath = $this->storageBasePath . '/' . $part->getStoragePath();
if (file_exists($partPath)) {
unlink($partPath);
}
$this->entityManager->remove($part);
}
// Remove multipart directory
$multipartDir = $this->storageBasePath . '/' . $upload->getStoragePathPrefix();
if (is_dir($multipartDir)) {
rmdir($multipartDir);
}
// Remove upload record
$this->entityManager->remove($upload);
$this->entityManager->flush();
return true;
}
public function listParts(S3MultipartUpload $upload): array
{
return $this->entityManager->getRepository(S3MultipartPart::class)
->findBy(['upload' => $upload], ['partNumber' => 'ASC']);
}
public function listMultipartUploads(S3Bucket $bucket): array
{
return $this->entityManager->getRepository(S3MultipartUpload::class)
->findBy(['bucket' => $bucket], ['initiatedAt' => 'ASC']);
}
// Presigned URL Management
public function createPresignedUrl(string $bucketName, string $objectKey, string $method, \DateTime $expiresAt, string $accessKey): S3PresignedUrl
{
$presignedUrl = new S3PresignedUrl();
$presignedUrl->setBucketName($bucketName)
->setObjectKey($objectKey)
->setMethod($method)
->setExpiresAt($expiresAt)
->setAccessKey($accessKey)
->setUrlHash(hash('sha256', $bucketName . $objectKey . $method . $expiresAt->getTimestamp() . $accessKey));
$this->entityManager->persist($presignedUrl);
$this->entityManager->flush();
return $presignedUrl;
}
public function findPresignedUrl(string $urlHash): ?S3PresignedUrl
{
return $this->entityManager->getRepository(S3PresignedUrl::class)
->findOneBy(['urlHash' => $urlHash]);
}
public function cleanupExpiredPresignedUrls(): int
{
$qb = $this->entityManager->createQueryBuilder();
$qb->delete(S3PresignedUrl::class, 'p')
->where('p.expiresAt < :now')
->setParameter('now', new \DateTime());
return $qb->getQuery()->execute();
}
// Helper Methods
private function generateStoragePath(string $bucketName, string $objectKey): string
{
// Create a nested directory structure based on object key hash to avoid too many files in one directory
$hash = hash('sha256', $objectKey);
$prefix = substr($hash, 0, 2) . '/' . substr($hash, 2, 2);
return $bucketName . '/objects/' . $prefix . '/' . $hash;
}
private function generateMultipartPrefix(string $bucketName, string $objectKey, string $uploadId): string
{
$hash = hash('sha256', $objectKey . $uploadId);
$prefix = substr($hash, 0, 2) . '/' . substr($hash, 2, 2);
return $bucketName . '/multipart/' . $prefix . '/' . $uploadId;
}
// AWS Signature V4 helpers
public function generatePresignedGetUrl(string $bucketName, string $objectKey, S3Credential $credential, int $expiresIn = 3600): string
{
$expiresAt = new \DateTime('+' . $expiresIn . ' seconds');
// Store in database for tracking
$presignedUrl = $this->createPresignedUrl($bucketName, $objectKey, 'GET', $expiresAt, $credential->getAccessKey());
// Generate the actual presigned URL (simplified - you'll need full AWS Signature V4 implementation)
$params = [
'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
'X-Amz-Credential' => $credential->getAccessKey() . '/' . date('Ymd') . '/us-east-1/s3/aws4_request',
'X-Amz-Date' => gmdate('Ymd\THis\Z'),
'X-Amz-Expires' => $expiresIn,
'X-Amz-SignedHeaders' => 'host',
'X-Amz-Signature' => $this->calculateSignature($bucketName, $objectKey, $params, $credential->getSecretKey()),
'hash' => $presignedUrl->getUrlHash()
];
return '/' . $bucketName . '/' . $objectKey . '?' . http_build_query($params);
}
private function calculateSignature(string $bucketName, string $objectKey, array $params, string $secretKey): string
{
// Simplified signature calculation - implement full AWS Signature V4 here
return hash_hmac('sha256', $bucketName . $objectKey . serialize($params), $secretKey);
}
}