434 lines
16 KiB
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);
|
|
}
|
|
}
|
|
}
|