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', ]; } }