nhance_partner_be/app/Services/StorageBrowserService.php

349 lines
11 KiB
PHP

<?php
namespace App\Services;
use App\Services\Storage\FileStorageException;
use Aws\Exception\AwsException;
use Aws\S3\S3Client;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Storage;
class StorageBrowserService
{
private Storage $config;
private FileStorageService $storage;
/** @var list<string> */
private array $folders = [
'uploads/policy/policy_pdf',
'uploads/policy/policy_payment_receipt',
'uploads/policy/policy_md',
'uploads/endorsement',
'uploads/endorsement/endorsement_pdf',
'uploads/agent/certificate_file',
'uploads/agent/incentive_file',
'uploads/enquiry/id_proof',
'uploads/enquiry/rc',
'uploads/enquiry/previous_policy',
'uploads/enquiry',
'uploads/quotation',
'uploads/claims',
];
/** @var list<string> */
private array $skipFiles = ['index.html', '.htaccess', '.gitkeep'];
public function __construct(?Storage $config = null, ?FileStorageService $storage = null)
{
$this->config = $config ?? config('Storage');
$this->storage = $storage ?? service('fileStorage');
}
public function driverLabel(): string
{
return $this->storage->isS3() ? 's3' : 'local';
}
public function bucketLabel(): string
{
if ($this->config->s3Bucket !== '') {
return $this->config->s3Prefix !== ''
? $this->config->s3Bucket . '/' . $this->config->s3Prefix
: $this->config->s3Bucket;
}
return $this->storage->isS3() ? 's3' : 'local-storage';
}
/**
* @return list<array{value: string, label: string}>
*/
public function folders(): array
{
$items = [];
foreach ($this->folders as $folder) {
$items[] = [
'value' => $folder,
'label' => $folder,
];
}
return $items;
}
/**
* @return array{
* files: list<array{key: string, file_name: string, size: int, size_human: string, last_modified: string}>,
* next_token: ?string,
* has_more: bool
* }
*/
public function listFiles(string $folder, int $perPage = 10, ?string $continuationToken = null, ?string $search = null): array
{
$folder = $this->normalizeFolder($folder);
$search = $search !== null ? trim($search) : '';
if ($this->storage->isS3()) {
return $this->listS3Files($folder, $perPage, $continuationToken, $search);
}
return $this->listLocalFiles($folder, $perPage, $continuationToken, $search);
}
public function assertAllowedKey(string $key): void
{
$key = ltrim(str_replace('\\', '/', $key), '/');
if ($key === '' || str_contains($key, '..')) {
throw new FileStorageException('Invalid file key.');
}
if (! str_starts_with($key, 'uploads/')) {
throw new FileStorageException('Access denied for this path.');
}
}
public function getOpenUrl(string $key): string
{
$this->assertAllowedKey($key);
if (! $this->storage->existsKey($key)) {
throw new FileStorageException('File not found.');
}
if ($this->storage->isS3()) {
return $this->storage->getTemporaryUrlForKey($key);
}
return site_url('storage/browser/download?key=' . rawurlencode($key));
}
public function downloadResponse(string $key): ResponseInterface
{
$this->assertAllowedKey($key);
return $this->storage->downloadKey($key, basename($key));
}
private function normalizeFolder(string $folder): string
{
$folder = trim(str_replace('\\', '/', $folder), '/');
if ($folder === '' || str_contains($folder, '..')) {
throw new FileStorageException('Invalid folder.');
}
if (! in_array($folder, $this->folders, true)) {
throw new FileStorageException('Folder is not allowed.');
}
return $folder;
}
/**
* @return array{
* files: list<array{key: string, file_name: string, size: int, size_human: string, last_modified: string}>,
* next_token: ?string,
* has_more: bool
* }
*/
private function listLocalFiles(string $folder, int $perPage, ?string $continuationToken, string $search): array
{
$dir = rtrim($this->config->localRoot, '/\\')
. DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folder);
if (! is_dir($dir)) {
return ['files' => [], 'next_token' => null, 'has_more' => false];
}
$offset = 0;
if ($continuationToken !== null && $continuationToken !== '') {
$decoded = json_decode(base64_decode($continuationToken, true) ?: '', true);
$offset = is_array($decoded) ? max(0, (int) ($decoded['offset'] ?? 0)) : 0;
}
$entries = [];
$handle = opendir($dir);
if ($handle === false) {
throw new FileStorageException('Unable to read folder.');
}
while (($entry = readdir($handle)) !== false) {
if ($entry === '.' || $entry === '..') {
continue;
}
if (in_array(strtolower($entry), $this->skipFiles, true)) {
continue;
}
$fullPath = $dir . DIRECTORY_SEPARATOR . $entry;
if (! is_file($fullPath)) {
continue;
}
if ($search !== '' && stripos($entry, $search) === false) {
continue;
}
$entries[] = [
'key' => $folder . '/' . $entry,
'file_name' => $entry,
'size' => (int) (filesize($fullPath) ?: 0),
'size_human' => $this->formatBytes((int) (filesize($fullPath) ?: 0)),
'last_modified' => date('Y-m-d H:i:s', (int) filemtime($fullPath)),
];
}
closedir($handle);
usort($entries, static fn (array $a, array $b): int => strcmp($b['last_modified'], $a['last_modified']));
$slice = array_slice($entries, $offset, $perPage);
$next = $offset + $perPage;
$hasMore = $next < count($entries);
return [
'files' => $slice,
'next_token' => $hasMore ? base64_encode(json_encode(['offset' => $next], JSON_THROW_ON_ERROR)) : null,
'has_more' => $hasMore,
];
}
/**
* @return array{
* files: list<array{key: string, file_name: string, size: int, size_human: string, last_modified: string}>,
* next_token: ?string,
* has_more: bool
* }
*/
private function listS3Files(string $folder, int $perPage, ?string $continuationToken, string $search): array
{
if ($this->config->s3Bucket === '' || $this->config->s3AccessKey === '' || $this->config->s3SecretKey === '') {
throw new FileStorageException('S3 is not configured.');
}
$client = new S3Client([
'version' => 'latest',
'region' => $this->config->s3Region,
'credentials' => [
'key' => $this->config->s3AccessKey,
'secret' => $this->config->s3SecretKey,
],
]);
$prefix = $this->objectKey($folder . '/');
$files = [];
$token = $continuationToken ?: null;
$hasMore = false;
try {
while (count($files) < $perPage) {
$params = [
'Bucket' => $this->config->s3Bucket,
'Prefix' => $prefix,
'MaxKeys' => max($perPage * 3, 30),
];
if ($token !== null) {
$params['ContinuationToken'] = $token;
}
$result = $client->listObjectsV2($params);
$items = $result['Contents'] ?? [];
foreach ($items as $item) {
$s3Key = (string) ($item['Key'] ?? '');
if ($s3Key === '' || str_ends_with($s3Key, '/')) {
continue;
}
$logicalKey = $this->stripObjectPrefix($s3Key);
$fileName = basename($logicalKey);
if (in_array(strtolower($fileName), $this->skipFiles, true)) {
continue;
}
if ($search !== '' && stripos($fileName, $search) === false) {
continue;
}
$files[] = [
'key' => $logicalKey,
'file_name' => $fileName,
'size' => (int) ($item['Size'] ?? 0),
'size_human' => $this->formatBytes((int) ($item['Size'] ?? 0)),
'last_modified' => isset($item['LastModified'])
? $item['LastModified']->format('Y-m-d H:i:s')
: '',
];
if (count($files) >= $perPage) {
break;
}
}
$token = $result['IsTruncated'] ? ($result['NextContinuationToken'] ?? null) : null;
$hasMore = $token !== null;
if ($token === null) {
break;
}
if (count($files) >= $perPage) {
break;
}
}
} catch (AwsException $e) {
throw new FileStorageException('S3 list failed: ' . $e->getAwsErrorMessage(), 0, $e);
}
return [
'files' => $files,
'next_token' => $hasMore ? $token : null,
'has_more' => $hasMore,
];
}
private function objectKey(string $key): string
{
$key = ltrim(str_replace('\\', '/', $key), '/');
return $this->config->s3Prefix !== ''
? $this->config->s3Prefix . '/' . $key
: $key;
}
private function stripObjectPrefix(string $s3Key): string
{
if ($this->config->s3Prefix === '') {
return $s3Key;
}
$prefix = $this->config->s3Prefix . '/';
return str_starts_with($s3Key, $prefix)
? substr($s3Key, strlen($prefix))
: $s3Key;
}
private function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
}
if ($bytes < 1048576) {
return round($bytes / 1024, 1) . ' KB';
}
return round($bytes / 1048576, 1) . ' MB';
}
}