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 $extra * @return array */ 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 $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); } } }