nhance/app/Libraries/FileStorageService.php
2026-07-15 15:49:13 +05:30

343 lines
11 KiB
PHP

<?php
namespace App\Libraries;
/**
* Thin wrapper over S3Service for general app uploads.
*
* Takes a local folder path + file name and maps the last path segment
* (e.g. client's uploads/client_kyc_documents) to the S3 folder/key prefix.
* Uses AWS_FILE_UPLOAD_BUCKET when set; falls back to AWS_BUCKET.
*/
class FileStorageService
{
protected $s3Service;
protected $bucket;
protected $driver;
public function __construct()
{
$this->s3Service = \Config\Services::getS3Service(false);
$this->bucket = getenv('AWS_FILE_UPLOAD_BUCKET') ?: getenv('AWS_BUCKET');
$this->driver = strtolower((string) (getenv('FILE_STORAGE_DRIVER') ?: 's3'));
}
public function usesS3(): bool
{
return $this->driver === 's3' && !empty($this->bucket);
}
public function getBucket(): ?string
{
return $this->bucket ?: null;
}
/**
* Convert a legacy local upload path to an S3 folder prefix.
* Last path segment becomes the bucket folder name.
* e.g. WRITEPATH/uploads/client_kyc_documents -> client_kyc_documents
*/
public function normalizeFolder(string $filepath): string
{
$path = str_replace('\\', '/', trim($filepath));
$path = rtrim($path, '/');
if ($path === '') {
return '';
}
$segments = array_values(array_filter(explode('/', $path), static function ($segment) {
return $segment !== '';
}));
return $segments ? (string) end($segments) : '';
}
public function buildKey(string $folder, string $fileName): string
{
$folder = trim($folder, '/');
$fileName = ltrim($fileName, '/');
return $folder !== '' ? $folder . '/' . $fileName : $fileName;
}
public function resolveLocalPath(string $filepath, string $fileName): string
{
return rtrim($filepath, '/\\') . DIRECTORY_SEPARATOR . $fileName;
}
/**
* Collect upload file metadata for logging.
*/
protected function getFileMeta($file, string $fallbackName): array
{
$isUploadedObject = is_object($file) && method_exists($file, 'isValid');
$resolvedName = $fallbackName;
$tmpPath = null;
$size = null;
$mimeType = null;
$extension = null;
$isValid = null;
$hasMoved = null;
$error = null;
if ($isUploadedObject) {
if (method_exists($file, 'getClientName')) {
$resolvedName = $file->getClientName() ?: $fallbackName;
}
$tmpPath = method_exists($file, 'getTempName') ? $file->getTempName() : null;
$size = method_exists($file, 'getSize') ? $file->getSize() : null;
$mimeType = method_exists($file, 'getMimeType') ? $file->getMimeType() : null;
$extension = method_exists($file, 'getExtension') ? $file->getExtension() : null;
$isValid = method_exists($file, 'isValid') ? $file->isValid() : null;
$hasMoved = method_exists($file, 'hasMoved') ? $file->hasMoved() : null;
$error = method_exists($file, 'getErrorString') ? $file->getErrorString() : null;
} elseif (is_string($file)) {
$tmpPath = $file;
$resolvedName = basename($file) ?: $fallbackName;
if (is_file($file)) {
$size = filesize($file);
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = $finfo ? finfo_file($finfo, $file) : null;
if ($finfo) {
finfo_close($finfo);
}
}
$extension = pathinfo($resolvedName, PATHINFO_EXTENSION);
}
return [
'resolved_name' => $resolvedName,
'tmp_path' => $tmpPath,
'size_bytes' => $size,
'mime_type' => $mimeType,
'extension' => $extension,
'is_valid' => $isValid,
'has_moved' => $hasMoved,
'upload_error' => $error,
];
}
protected function logUpload(string $event, array $details, string $level = 'info'): void
{
log_message($level, 'FileStorage Upload ' . $event . ' | ' . json_encode($details, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
/**
* Upload a file using folder path + file name.
*
* @return array{success:bool,key:?string,file_name:string,message:string,url:?string}
*/
public function upload($file, string $filepath, string $fileName): array
{
$folder = $this->normalizeFolder($filepath);
$key = $this->buildKey($folder, $fileName);
$fileMeta = $this->getFileMeta($file, $fileName);
$baseLog = [
'driver' => $this->driver,
'uses_s3' => $this->usesS3(),
'bucket' => $this->bucket,
'filepath' => $filepath,
'folder' => $folder,
'key' => $key,
'file_name' => $fileName,
'file' => $fileMeta,
];
$this->logUpload('started', $baseLog);
if ($this->usesS3()) {
try {
$result = $this->s3Service->upload($file, $folder, $fileName, $this->bucket);
$response = [
'success' => (bool) ($result['success'] ?? false),
'key' => $result['key'] ?? $key,
'file_name' => $fileName,
'url' => $result['url'] ?? null,
'message' => $result['message'] ?? '',
];
if ($response['success']) {
$this->logUpload('s3_success', array_merge($baseLog, [
'result' => $response,
's3_result' => $result,
]));
} else {
$this->logUpload('s3_failure', array_merge($baseLog, [
'result' => $response,
's3_result' => $result,
]), 'error');
}
return $response;
} catch (\Throwable $e) {
$response = [
'success' => false,
'key' => null,
'file_name' => $fileName,
'url' => null,
'message' => 'Upload exception: ' . $e->getMessage(),
];
$this->logUpload('s3_exception', array_merge($baseLog, [
'result' => $response,
'error_message' => $e->getMessage(),
'error_code' => $e->getCode(),
'exception_class' => get_class($e),
'error_file' => $e->getFile(),
'error_line' => $e->getLine(),
]), 'error');
return $response;
}
}
if (is_object($file) && method_exists($file, 'isValid') && $file->isValid() && !$file->hasMoved()) {
try {
$targetDir = rtrim($filepath, '/\\');
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}
$file->move($targetDir . DIRECTORY_SEPARATOR, $fileName);
$response = [
'success' => true,
'key' => $key,
'file_name' => $fileName,
'url' => null,
'message' => 'File uploaded successfully',
'local_path' => $this->resolveLocalPath($filepath, $fileName),
];
$this->logUpload('local_success', array_merge($baseLog, [
'result' => $response,
]));
return $response;
} catch (\Throwable $e) {
$response = [
'success' => false,
'key' => null,
'file_name' => $fileName,
'url' => null,
'message' => 'Local upload failed: ' . $e->getMessage(),
];
$this->logUpload('local_failure', array_merge($baseLog, [
'result' => $response,
'error_message' => $e->getMessage(),
'exception_class' => get_class($e),
'error_file' => $e->getFile(),
'error_line' => $e->getLine(),
]), 'error');
return $response;
}
}
$response = [
'success' => false,
'key' => null,
'file_name' => $fileName,
'url' => null,
'message' => 'Invalid file upload',
];
$this->logUpload('validation_failed', array_merge($baseLog, [
'result' => $response,
]), 'error');
return $response;
}
public function exists(string $filepath, string $fileName): bool
{
$folder = $this->normalizeFolder($filepath);
$key = $this->buildKey($folder, $fileName);
if ($this->usesS3()) {
return $this->s3Service->exists($key, $this->bucket);
}
return is_file($this->resolveLocalPath($filepath, $fileName));
}
public function getPresignedUrl(string $filepath, string $fileName, int $expiration = 60): array
{
$folder = $this->normalizeFolder($filepath);
$key = $this->buildKey($folder, $fileName);
if (!$this->usesS3()) {
return [
'success' => false,
'url' => null,
'message' => 'Presigned URLs are only available when S3 storage is enabled',
];
}
return $this->s3Service->getPresignedUrl($key, $expiration, $this->bucket);
}
/**
* Download file contents for forced browser download (attachment).
*
* @return array{success:bool,content:?string,path:?string,message:string}
*/
public function download(string $filepath, string $fileName): array
{
$localPath = $this->resolveLocalPath($filepath, $fileName);
if (is_file($localPath)) {
return [
'success' => true,
'content' => null,
'path' => $localPath,
'message' => 'Local file found',
];
}
if (!$this->usesS3()) {
return [
'success' => false,
'content' => null,
'path' => null,
'message' => 'File not found',
];
}
$folder = $this->normalizeFolder($filepath);
$key = $this->buildKey($folder, $fileName);
$result = $this->s3Service->download($key, null, $this->bucket);
return [
'success' => (bool) ($result['success'] ?? false),
'content' => $result['content'] ?? null,
'path' => null,
'message' => $result['message'] ?? 'Download failed',
'key' => $key,
];
}
public function delete(string $filepath, string $fileName): array
{
$folder = $this->normalizeFolder($filepath);
$key = $this->buildKey($folder, $fileName);
$localPath = $this->resolveLocalPath($filepath, $fileName);
// Always remove leftover local copy when present (S3-only cleanup).
if (is_file($localPath)) {
@unlink($localPath);
}
if ($this->usesS3()) {
return $this->s3Service->delete($key, $this->bucket);
}
return [
'success' => true,
'message' => 'File deleted successfully',
];
}
}