343 lines
11 KiB
PHP
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',
|
|
];
|
|
}
|
|
}
|