*/ 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 */ 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 */ public function folders(): array { $items = []; foreach ($this->folders as $folder) { $items[] = [ 'value' => $folder, 'label' => $folder, ]; } return $items; } /** * @return array{ * files: list, * 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, * 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, * 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'; } }