nhance_partner_be/app/Services/Storage/S3StorageDriver.php

434 lines
16 KiB
PHP

<?php
namespace App\Services\Storage;
use Aws\Exception\AwsException;
use Aws\S3\S3Client;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Storage;
class S3StorageDriver implements FileStorageInterface
{
private S3Client $client;
public function __construct(private readonly Storage $config)
{
StorageLogger::log('SUCCESS', 'S3 driver initializing', $this->baseContext());
try {
$this->assertConfigured();
$this->client = $this->createClient();
StorageLogger::log('SUCCESS', 'S3 driver initialized', $this->baseContext());
} catch (\Throwable $e) {
StorageLogger::log('FAILURE', 'S3 driver initialization failed', $this->baseContext([
'error' => $e->getMessage(),
]));
throw $e;
}
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
private function baseContext(array $extra = []): array
{
return array_merge([
'driver' => 's3',
'bucket' => $this->config->s3Bucket,
'region' => $this->config->s3Region,
'prefix' => $this->config->s3Prefix !== '' ? $this->config->s3Prefix : null,
], $extra);
}
private function assertConfigured(): void
{
if ($this->config->s3Bucket === '' || $this->config->s3AccessKey === '' || $this->config->s3SecretKey === '') {
throw new FileStorageException('S3 storage is not configured. Check AWS_BUCKET, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY.');
}
}
private function createClient(): S3Client
{
return new S3Client([
'version' => 'latest',
'region' => $this->config->s3Region,
'credentials' => [
'key' => $this->config->s3AccessKey,
'secret' => $this->config->s3SecretKey,
],
]);
}
private function objectKey(string $key): string
{
$key = ltrim(str_replace('\\', '/', $key), '/');
return $this->config->s3Prefix !== ''
? $this->config->s3Prefix . '/' . $key
: $key;
}
private function resolveContentType(UploadedFile|string $source, array $options): string
{
if (! empty($options['content_type'])) {
return (string) $options['content_type'];
}
if ($source instanceof UploadedFile) {
return $source->getClientMimeType() ?: 'application/octet-stream';
}
$mimeType = mime_content_type($source);
return $mimeType !== false ? $mimeType : 'application/octet-stream';
}
/**
* @param array<string, mixed> $params
*/
private function putObject(array $params, string $key, string $operation): void
{
StorageLogger::log('SUCCESS', "{$operation} started", $this->baseContext([
'key' => $key,
's3_key' => $params['Key'] ?? null,
'content_type' => $params['ContentType'] ?? null,
'body_size' => isset($params['Body']) ? strlen((string) $params['Body']) : null,
]));
try {
$this->client->putObject($params);
StorageLogger::log('SUCCESS', "{$operation} complete", $this->baseContext([
'key' => $key,
's3_key' => $params['Key'] ?? null,
]));
} catch (AwsException $e) {
StorageLogger::log('FAILURE', "{$operation} failed", $this->baseContext([
'key' => $key,
's3_key' => $params['Key'] ?? null,
'aws_code' => $e->getAwsErrorCode(),
'aws_message'=> $e->getAwsErrorMessage(),
]));
throw new FileStorageException('S3 upload failed: ' . $e->getAwsErrorMessage(), 0, $e);
}
}
public function upload(UploadedFile|string $source, string $key, array $options = []): string
{
$params = [
'Bucket' => $this->config->s3Bucket,
'Key' => $this->objectKey($key),
'ContentType' => $this->resolveContentType($source, $options),
];
if ($source instanceof UploadedFile) {
if (! $source->isValid()) {
StorageLogger::log('FAILURE', 'Upload rejected: invalid uploaded file', $this->baseContext([
'key' => $key,
'error' => $source->getErrorString() ?: 'Invalid uploaded file',
]));
throw new FileStorageException($source->getErrorString() ?: 'Invalid uploaded file');
}
$params['SourceFile'] = $source->getTempName();
StorageLogger::log('SUCCESS', 'Upload source resolved from uploaded file', $this->baseContext([
'key' => $key,
'original_name' => $source->getClientName(),
'client_mime' => $source->getClientMimeType(),
'size' => $source->getSize(),
]));
} else {
if (! is_file($source)) {
StorageLogger::log('FAILURE', 'Upload rejected: source file not found', $this->baseContext([
'key' => $key,
'source' => $source,
]));
throw new FileStorageException("Source file not found: {$source}");
}
$params['SourceFile'] = $source;
StorageLogger::log('SUCCESS', 'Upload source resolved from filesystem path', $this->baseContext([
'key' => $key,
'source' => $source,
'size' => filesize($source) ?: 0,
]));
}
$this->putObject($params, $key, 'Upload');
return $key;
}
public function put(string $key, string $contents, array $options = []): string
{
$this->putObject([
'Bucket' => $this->config->s3Bucket,
'Key' => $this->objectKey($key),
'Body' => $contents,
'ContentType' => $options['content_type'] ?? 'application/octet-stream',
], $key, 'Put');
return $key;
}
public function exists(string $key): bool
{
StorageLogger::log('SUCCESS', 'Exists check started', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
]));
try {
$exists = $this->client->doesObjectExist(
$this->config->s3Bucket,
$this->objectKey($key)
);
StorageLogger::log('SUCCESS', 'Exists check complete', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'exists' => $exists,
]));
return $exists;
} catch (AwsException $e) {
StorageLogger::log('FAILURE', 'Exists check failed', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'aws_code' => $e->getAwsErrorCode(),
'aws_message' => $e->getAwsErrorMessage(),
]));
throw new FileStorageException('S3 exists check failed: ' . $e->getAwsErrorMessage(), 0, $e);
}
}
public function delete(string $key): bool
{
StorageLogger::log('SUCCESS', 'Delete started', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
]));
try {
$this->client->deleteObject([
'Bucket' => $this->config->s3Bucket,
'Key' => $this->objectKey($key),
]);
StorageLogger::log('SUCCESS', 'Delete complete', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
]));
return true;
} catch (AwsException $e) {
StorageLogger::log('FAILURE', 'Delete failed', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'aws_code' => $e->getAwsErrorCode(),
'aws_message' => $e->getAwsErrorMessage(),
]));
throw new FileStorageException('S3 delete failed: ' . $e->getAwsErrorMessage(), 0, $e);
}
}
public function read(string $key): string
{
StorageLogger::log('SUCCESS', 'Read started', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
]));
try {
$result = $this->client->getObject([
'Bucket' => $this->config->s3Bucket,
'Key' => $this->objectKey($key),
]);
$body = (string) $result['Body'];
StorageLogger::log('SUCCESS', 'Read complete', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'size' => strlen($body),
'mime_type' => $result['ContentType'] ?? null,
]));
return $body;
} catch (AwsException $e) {
StorageLogger::log('FAILURE', 'Read failed', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'aws_code' => $e->getAwsErrorCode(),
'aws_message' => $e->getAwsErrorMessage(),
]));
throw new FileStorageException('S3 read failed: ' . $e->getAwsErrorMessage(), 0, $e);
}
}
public function downloadToTemp(string $key): string
{
$tempPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('s3_', true) . '_' . basename($key);
StorageLogger::log('SUCCESS', 'Download to temp started', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'temp_path' => $tempPath,
]));
try {
$this->client->getObject([
'Bucket' => $this->config->s3Bucket,
'Key' => $this->objectKey($key),
'SaveAs' => $tempPath,
]);
StorageLogger::log('SUCCESS', 'Download to temp complete', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'temp_path' => $tempPath,
'size' => is_file($tempPath) ? (filesize($tempPath) ?: 0) : 0,
]));
return $tempPath;
} catch (AwsException $e) {
StorageLogger::log('FAILURE', 'Download to temp failed', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'temp_path' => $tempPath,
'aws_code' => $e->getAwsErrorCode(),
'aws_message' => $e->getAwsErrorMessage(),
]));
throw new FileStorageException('S3 download failed: ' . $e->getAwsErrorMessage(), 0, $e);
}
}
public function streamDownload(string $key, ?string $downloadName = null): ResponseInterface
{
StorageLogger::log('SUCCESS', 'Stream download started', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'download_name' => $downloadName ?: basename($key),
]));
try {
$result = $this->client->getObject([
'Bucket' => $this->config->s3Bucket,
'Key' => $this->objectKey($key),
]);
$body = (string) $result['Body'];
StorageLogger::log('SUCCESS', 'Stream download complete', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'download_name' => $downloadName ?: basename($key),
'size' => strlen($body),
'mime_type' => $result['ContentType'] ?? null,
]));
return service('response')
->setHeader('Content-Type', $result['ContentType'] ?? 'application/octet-stream')
->setHeader(
'Content-Disposition',
'attachment; filename="' . ($downloadName ?: basename($key)) . '"'
)
->setBody($body);
} catch (AwsException $e) {
StorageLogger::log('FAILURE', 'Stream download failed', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'aws_code' => $e->getAwsErrorCode(),
'aws_message' => $e->getAwsErrorMessage(),
]));
throw new FileStorageException('S3 download failed: ' . $e->getAwsErrorMessage(), 0, $e);
}
}
public function temporaryUrl(string $key, int $ttlSeconds = 900): string
{
StorageLogger::log('SUCCESS', 'Presigned URL generation started', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'ttl_seconds' => $ttlSeconds,
]));
try {
$command = $this->client->getCommand('GetObject', [
'Bucket' => $this->config->s3Bucket,
'Key' => $this->objectKey($key),
]);
$request = $this->client->createPresignedRequest($command, "+{$ttlSeconds} seconds");
StorageLogger::log('SUCCESS', 'Presigned URL generation complete', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'ttl_seconds' => $ttlSeconds,
]));
return (string) $request->getUri();
} catch (AwsException $e) {
StorageLogger::log('FAILURE', 'Presigned URL generation failed', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
'ttl_seconds' => $ttlSeconds,
'aws_code' => $e->getAwsErrorCode(),
'aws_message' => $e->getAwsErrorMessage(),
]));
throw new FileStorageException('S3 presigned URL failed: ' . $e->getAwsErrorMessage(), 0, $e);
}
}
public function url(string $key): string
{
$url = sprintf(
'https://%s.s3.%s.amazonaws.com/%s',
$this->config->s3Bucket,
$this->config->s3Region,
rawurlencode($this->objectKey($key))
);
StorageLogger::log('SUCCESS', 'Public URL resolved', $this->baseContext([
'key' => $key,
's3_key' => $this->objectKey($key),
]));
return $url;
}
public function copy(string $fromKey, string $toKey): bool
{
StorageLogger::log('SUCCESS', 'Copy started', $this->baseContext([
'from_key' => $fromKey,
'to_key' => $toKey,
'from_s3_key' => $this->objectKey($fromKey),
'to_s3_key' => $this->objectKey($toKey),
]));
try {
$this->client->copyObject([
'Bucket' => $this->config->s3Bucket,
'CopySource' => $this->config->s3Bucket . '/' . $this->objectKey($fromKey),
'Key' => $this->objectKey($toKey),
]);
StorageLogger::log('SUCCESS', 'Copy complete', $this->baseContext([
'from_key' => $fromKey,
'to_key' => $toKey,
]));
return true;
} catch (AwsException $e) {
StorageLogger::log('FAILURE', 'Copy failed', $this->baseContext([
'from_key' => $fromKey,
'to_key' => $toKey,
'aws_code' => $e->getAwsErrorCode(),
'aws_message' => $e->getAwsErrorMessage(),
]));
throw new FileStorageException('S3 copy failed: ' . $e->getAwsErrorMessage(), 0, $e);
}
}
}