init commit
This commit is contained in:
0
src/Controller/.gitignore
vendored
Normal file
0
src/Controller/.gitignore
vendored
Normal file
434
src/Controller/ConsoleApiController.php
Normal file
434
src/Controller/ConsoleApiController.php
Normal 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];
|
||||
}
|
||||
}
|
||||
14
src/Controller/ConsoleController.php
Normal file
14
src/Controller/ConsoleController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
16
src/Controller/DocsController.php
Normal file
16
src/Controller/DocsController.php
Normal 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', [ ]);
|
||||
}
|
||||
}
|
||||
348
src/Controller/S3ApiController.php
Normal file
348
src/Controller/S3ApiController.php
Normal 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
0
src/Entity/.gitignore
vendored
Normal file
57
src/Entity/S3Bucket.php
Normal file
57
src/Entity/S3Bucket.php
Normal 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; }
|
||||
}
|
||||
61
src/Entity/S3Credential.php
Normal file
61
src/Entity/S3Credential.php
Normal 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; }
|
||||
}
|
||||
57
src/Entity/S3MultipartPart.php
Normal file
57
src/Entity/S3MultipartPart.php
Normal 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; }
|
||||
}
|
||||
79
src/Entity/S3MultipartUpload.php
Normal file
79
src/Entity/S3MultipartUpload.php
Normal 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
85
src/Entity/S3Object.php
Normal 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; }
|
||||
}
|
||||
67
src/Entity/S3PresignedUrl.php
Normal file
67
src/Entity/S3PresignedUrl.php
Normal 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
11
src/Kernel.php
Normal 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
0
src/Repository/.gitignore
vendored
Normal file
430
src/Service/S3Service.php
Normal file
430
src/Service/S3Service.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user