nhance/app/Libraries/S3Service.php

459 lines
16 KiB
PHP

<?php
namespace App\Libraries;
use Aws\S3\S3Client;
use Aws\Exception\AwsException;
class S3Service
{
protected $s3Client;
protected $bucket;
protected $region;
protected $baseUrl;
public function __construct()
{
$this->bucket = getenv('AWS_BUCKET');
$this->region = getenv('AWS_DEFAULT_REGION');
$this->s3Client = new S3Client([
'version' => 'latest',
'region' => $this->region,
'credentials' => [
'key' => getenv('AWS_ACCESS_KEY_ID'),
'secret' => getenv('AWS_SECRET_ACCESS_KEY'),
],
]);
$this->baseUrl = $this->buildBaseUrl($this->bucket);
}
protected function resolveBucket(?string $bucket = null): string
{
return $bucket ?: $this->bucket;
}
protected function buildBaseUrl(string $bucket): string
{
return "https://{$bucket}.s3.{$this->region}.amazonaws.com/";
}
/**
* Upload file to S3
*
* @param mixed $file File object or file path
* @param string $folder Folder path in S3 bucket (optional)
* @param string $fileName Custom file name (optional)
* @param string|null $bucket Override destination bucket (optional)
* @return array ['success' => bool, 'url' => string, 'key' => string, 'message' => string]
*/
public function upload($file, string $folder = '', string $fileName = null, ?string $bucket = null): array
{
$targetBucket = $this->resolveBucket($bucket);
$startLog = [
'bucket' => $targetBucket,
'region' => $this->region,
'folder' => $folder,
'file_name' => $fileName,
'file_type' => is_object($file) ? get_class($file) : gettype($file),
];
log_message('info', 'S3 Upload started | ' . json_encode($startLog, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
try {
// Handle CodeIgniter file upload object
if (is_object($file) && method_exists($file, 'isValid')) {
if (!$file->isValid()) {
$fail = [
'success' => false,
'message' => 'Invalid file upload',
'url' => null,
'key' => null
];
log_message('error', 'S3 Upload validation failed | ' . json_encode(array_merge($startLog, [
'result' => $fail,
'error' => method_exists($file, 'getErrorString') ? $file->getErrorString() : null,
]), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return $fail;
}
$filePath = $file->getTempName();
$originalName = $fileName ?? $file->getClientName();
$sizeBytes = method_exists($file, 'getSize') ? $file->getSize() : null;
} else {
// Handle file path string
$filePath = $file;
$originalName = $fileName ?? basename($file);
$sizeBytes = is_string($filePath) && is_file($filePath) ? filesize($filePath) : null;
}
// Generate unique file name
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
// $uniqueName = pathinfo($originalName, PATHINFO_FILENAME) . '_' . time() . '.' . $extension;
$uniqueName = pathinfo($originalName, PATHINFO_FILENAME) . '.' . $extension;
// Build S3 key (path)
$key = $folder ? rtrim($folder, '/') . '/' . $uniqueName : $uniqueName;
// Upload to S3
// NEW - Faster with streaming and proper content type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $filePath);
finfo_close($finfo);
$result = $this->s3Client->putObject([
'Bucket' => $targetBucket,
'Key' => $key,
'Body' => fopen($filePath, 'rb'), // Stream instead of loading into memory
'ContentType' => $mimeType,
// 'ACL' => 'public-read',
]);
$success = [
'success' => true,
'url' => $this->buildBaseUrl($targetBucket) . $key,
'key' => $key,
'message' => 'File uploaded successfully'
];
log_message('info', 'S3 Upload successful | ' . json_encode(array_merge($startLog, [
'tmp_path' => $filePath,
'original_name' => $originalName,
'unique_name' => $uniqueName,
'key' => $key,
'mime_type' => $mimeType,
'size_bytes' => $sizeBytes,
'etag' => $result['ETag'] ?? null,
'object_url' => $result['ObjectURL'] ?? null,
'result' => $success,
]), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return $success;
} catch (AwsException $e) {
log_message('error', 'S3 Upload Error | ' . json_encode(array_merge($startLog, [
'error_message' => $e->getMessage(),
'aws_error_code' => $e->getAwsErrorCode(),
'aws_error_type' => $e->getAwsErrorType(),
'status_code' => $e->getStatusCode(),
'request_id' => $e->getAwsRequestId(),
]), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return [
'success' => false,
'message' => 'Upload failed: ' . $e->getMessage(),
'url' => null,
'key' => null
];
} catch (\Throwable $e) {
log_message('error', 'S3 Upload Exception | ' . json_encode(array_merge($startLog, [
'error_message' => $e->getMessage(),
'exception_class' => get_class($e),
'error_file' => $e->getFile(),
'error_line' => $e->getLine(),
]), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return [
'success' => false,
'message' => 'Upload failed: ' . $e->getMessage(),
'url' => null,
'key' => null
];
}
}
/**
* Download file from S3
*
* @param string $key S3 object key (file path in bucket)
* @param string $savePath Local path to save the file (optional)
* @param string|null $bucket Override source bucket (optional)
* @return array ['success' => bool, 'path' => string, 'content' => string, 'message' => string]
*/
public function download(string $key, string $savePath = null, ?string $bucket = null): array
{
try {
$result = $this->s3Client->getObject([
'Bucket' => $this->resolveBucket($bucket),
'Key' => $key,
]);
$content = (string) $result['Body'];
// If save path is provided, save to local file
if ($savePath) {
$directory = dirname($savePath);
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
file_put_contents($savePath, $content);
return [
'success' => true,
'path' => $savePath,
'content' => $content,
'message' => 'File downloaded successfully'
];
}
return [
'success' => true,
'path' => null,
'content' => $content,
'message' => 'File content retrieved successfully'
];
} catch (AwsException $e) {
log_message('error', 'S3 Download Error: ' . $e->getMessage());
return [
'success' => false,
'path' => null,
'content' => null,
'message' => 'Download failed: ' . $e->getMessage()
];
}
}
/**
* Get a pre-signed URL for temporary access to a private file
*
* @param string $key S3 object key
* @param int $expiration Expiration time in minutes (default: 60)
* @param string|null $bucket Override source bucket (optional)
* @return array ['success' => bool, 'url' => string, 'message' => string]
*/
public function getPresignedUrl(string $key, int $expiration = 60, ?string $bucket = null): array
{
try {
$cmd = $this->s3Client->getCommand('GetObject', [
'Bucket' => $this->resolveBucket($bucket),
'Key' => $key
]);
$request = $this->s3Client->createPresignedRequest($cmd, "+{$expiration} minutes");
$presignedUrl = (string) $request->getUri();
return [
'success' => true,
'url' => $presignedUrl,
'message' => 'Pre-signed URL generated successfully'
];
} catch (AwsException $e) {
log_message('error', 'S3 Presigned URL Error: ' . $e->getMessage());
return [
'success' => false,
'url' => null,
'message' => 'Failed to generate URL: ' . $e->getMessage()
];
}
}
/**
* Delete file from S3
*
* @param string $key S3 object key
* @param string|null $bucket Override source bucket (optional)
* @return array ['success' => bool, 'message' => string]
*/
public function delete(string $key, ?string $bucket = null): array
{
try {
$this->s3Client->deleteObject([
'Bucket' => $this->resolveBucket($bucket),
'Key' => $key,
]);
return [
'success' => true,
'message' => 'File deleted successfully'
];
} catch (AwsException $e) {
log_message('error', 'S3 Delete Error: ' . $e->getMessage());
return [
'success' => false,
'message' => 'Delete failed: ' . $e->getMessage()
];
}
}
/**
* Check if file exists in S3
*
* @param string $key S3 object key
* @param string|null $bucket Override source bucket (optional)
* @return bool
*/
public function exists(string $key, ?string $bucket = null): bool
{
return $this->s3Client->doesObjectExist($this->resolveBucket($bucket), $key);
}
/**
* List files in S3 bucket
*
* @param string $prefix Folder prefix (optional)
* @param string|null $bucket Override source bucket (optional)
* @return array ['success' => bool, 'files' => array, 'message' => string]
*/
/**
* List files in S3 bucket with optional MaxKeys / ContinuationToken pagination.
*
* @return array{success:bool,files:array,next_token:?string,is_truncated:bool,message:string}
*/
public function listFilesPage(string $prefix = '', int $maxKeys = 100, ?string $continuationToken = null, ?string $bucket = null): array
{
try {
$targetBucket = $this->resolveBucket($bucket);
$params = [
'Bucket' => $targetBucket,
'Prefix' => $prefix,
'MaxKeys' => max(1, min(1000, $maxKeys)),
];
if ($continuationToken !== null && $continuationToken !== '') {
$params['ContinuationToken'] = $continuationToken;
}
$result = $this->s3Client->listObjectsV2($params);
$files = [];
if (isset($result['Contents'])) {
foreach ($result['Contents'] as $object) {
$key = (string) ($object['Key'] ?? '');
if ($key === '' || substr($key, -1) === '/') {
continue;
}
$files[] = [
'key' => $key,
'size' => (int) ($object['Size'] ?? 0),
'last_modified' => $object['LastModified']->format('Y-m-d H:i:s'),
'url' => $this->buildBaseUrl($targetBucket) . $key,
];
}
}
$isTruncated = ! empty($result['IsTruncated']);
$nextToken = $isTruncated ? ($result['NextContinuationToken'] ?? null) : null;
return [
'success' => true,
'files' => $files,
'next_token' => $nextToken,
'is_truncated' => $isTruncated,
'message' => 'Files retrieved successfully',
];
} catch (AwsException $e) {
log_message('error', 'S3 List Page Error: ' . $e->getMessage());
return [
'success' => false,
'files' => [],
'next_token' => null,
'is_truncated' => false,
'message' => 'Failed to list files: ' . $e->getMessage(),
];
}
}
/**
* List top-level "folders" (common prefixes) in the bucket.
*
* @return array{success:bool,folders:list<string>,message:string}
*/
public function listRootFolders(?string $bucket = null): array
{
try {
$targetBucket = $this->resolveBucket($bucket);
$folders = [];
$token = null;
do {
$params = [
'Bucket' => $targetBucket,
'Delimiter' => '/',
'MaxKeys' => 1000,
];
if ($token !== null) {
$params['ContinuationToken'] = $token;
}
$result = $this->s3Client->listObjectsV2($params);
if (! empty($result['CommonPrefixes'])) {
foreach ($result['CommonPrefixes'] as $prefixRow) {
$p = rtrim((string) ($prefixRow['Prefix'] ?? ''), '/');
if ($p !== '') {
$folders[] = $p;
}
}
}
$token = ! empty($result['IsTruncated'])
? ($result['NextContinuationToken'] ?? null)
: null;
} while ($token !== null);
$folders = array_values(array_unique($folders));
sort($folders);
return [
'success' => true,
'folders' => $folders,
'message' => 'Folders retrieved successfully',
];
} catch (AwsException $e) {
log_message('error', 'S3 List Folders Error: ' . $e->getMessage());
return [
'success' => false,
'folders' => [],
'message' => 'Failed to list folders: ' . $e->getMessage(),
];
}
}
public function listFiles(string $prefix = '', ?string $bucket = null): array
{
try {
$targetBucket = $this->resolveBucket($bucket);
$files = [];
$token = null;
do {
$params = [
'Bucket' => $targetBucket,
'Prefix' => $prefix,
];
if ($token !== null) {
$params['ContinuationToken'] = $token;
}
$result = $this->s3Client->listObjectsV2($params);
if (isset($result['Contents'])) {
foreach ($result['Contents'] as $object) {
$files[] = [
'key' => $object['Key'],
'size' => $object['Size'],
'last_modified' => $object['LastModified']->format('Y-m-d H:i:s'),
'url' => $this->buildBaseUrl($targetBucket) . $object['Key'],
];
}
}
$token = ! empty($result['IsTruncated'])
? ($result['NextContinuationToken'] ?? null)
: null;
} while ($token !== null);
return [
'success' => true,
'files' => $files,
'message' => 'Files retrieved successfully',
];
} catch (AwsException $e) {
log_message('error', 'S3 List Error: ' . $e->getMessage());
return [
'success' => false,
'files' => [],
'message' => 'Failed to list files: ' . $e->getMessage(),
];
}
}
}