341 lines
10 KiB
PHP
341 lines
10 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',
|
|
];
|
|
|
|
/** @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';
|
|
}
|
|
}
|