'S3 Files', 'page_title' => 'S3 File Browser', 'bucket' => $storage->getBucket(), 'uses_s3' => $storage->usesS3(), 'list_url' => base_url('storage/files/list'), 'folders_url' => base_url('storage/files/folders'), ]; $this->loadLayout('storage_files', $data); } /** * GET /storage/files/folders */ public function folders() { $storage = \Config\Services::getFileStorageService(false); if (! $storage->usesS3()) { return $this->response->setJSON([ 'success' => false, 'folders' => [], 'message' => 'S3 storage is not enabled', ])->setStatusCode(400); } $s3 = \Config\Services::getS3Service(false); $listed = $s3->listRootFolders($storage->getBucket()); $fromS3 = $listed['folders'] ?? []; // Merge known local upload module folders so empty S3 prefixes still appear. $local = $this->localUploadFolders(); $folders = array_values(array_unique(array_merge($fromS3, $local))); sort($folders); return $this->response->setJSON([ 'success' => true, 'bucket' => $storage->getBucket(), 'folders' => $folders, 'message' => $listed['message'] ?? 'OK', ]); } /** * GET /storage/files/list?folder=&search=&page=1&per_page=10 * * Fetches from S3 in small batches; stops once the requested page * (+1 row to detect has_more) is satisfied. Does not load the whole bucket. */ public function listFiles() { $storage = \Config\Services::getFileStorageService(false); if (! $storage->usesS3()) { return $this->response->setJSON([ 'success' => false, 'message' => 'S3 storage is not enabled', ])->setStatusCode(400); } $folder = basename(str_replace('\\', '/', trim((string) $this->request->getGet('folder')))); if ($folder === '' || $folder === '.' || $folder === '..' || preg_match('/[^a-zA-Z0-9_\-]/', $folder)) { return $this->response->setJSON([ 'success' => false, 'message' => 'Invalid folder name', ])->setStatusCode(400); } $search = trim((string) $this->request->getGet('search')); $page = max(1, (int) ($this->request->getGet('page') ?? 1)); $perPage = (int) ($this->request->getGet('per_page') ?? self::PER_PAGE); $perPage = max(1, min(50, $perPage)); $needRows = ($page * $perPage) + 1; // one extra → has_more $s3 = \Config\Services::getS3Service(false); $bucket = $storage->getBucket(); $prefix = $folder . '/'; $matched = []; $token = null; $s3More = true; $listErr = null; while (count($matched) < $needRows && $s3More) { $batch = $s3->listFilesPage($prefix, 100, $token, $bucket); if (! ($batch['success'] ?? false)) { $listErr = $batch['message'] ?? 'List failed'; break; } foreach ($batch['files'] ?? [] as $file) { $key = (string) ($file['key'] ?? ''); $fileName = basename($key); if ($search !== '' && stripos($fileName, $search) === false) { continue; } $matched[] = [ 'key' => $key, 'file_name' => $fileName, 'size' => (int) ($file['size'] ?? 0), 'size_label' => $this->formatBytes((int) ($file['size'] ?? 0)), 'last_modified' => (string) ($file['last_modified'] ?? ''), ]; } $token = $batch['next_token'] ?? null; $s3More = ! empty($batch['is_truncated']) && $token !== null; } if ($listErr !== null) { return $this->response->setJSON([ 'success' => false, 'message' => $listErr, ])->setStatusCode(500); } $offset = ($page - 1) * $perPage; $pageRows = array_slice($matched, $offset, $perPage); $hasMore = count($matched) > ($offset + count($pageRows)) || ($s3More && count($pageRows) === $perPage); // Optional short-lived download links for the 10 visible rows only. foreach ($pageRows as &$row) { $presigned = $storage->getPresignedUrl( WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . $folder, $row['file_name'], 300 ); $row['download_url'] = ($presigned['success'] ?? false) ? ($presigned['url'] ?? null) : null; } unset($row); return $this->response->setJSON([ 'success' => true, 'bucket' => $bucket, 'folder' => $folder, 'search' => $search, 'page' => $page, 'per_page' => $perPage, 'has_more' => $hasMore, 'has_previous' => $page > 1, 'count' => count($pageRows), 'files' => $pageRows, 'message' => 'OK', ]); } /** * GET /storage/file-download/claim/{md5(id)} * Streams claim_files row by MD5(id) as attachment (never inline). */ public function forceDownloadClaim($md5Id = null) { helper('utility'); $md5Id = strtolower(trim((string) $md5Id)); if ($md5Id === '' || ! preg_match('/^[a-f0-9]{32}$/', $md5Id)) { throw PageNotFoundException::forPageNotFound(); } $claimFiles = new ClaimFilesModel(); $record = $claimFiles ->where('MD5(CAST(id AS CHAR))', $md5Id) ->where('is_active', 1) ->first(); if (empty($record)) { throw PageNotFoundException::forPageNotFound(); } $diskName = ! empty($record['url']) ? basename((string) $record['url']) : basename((string) ($record['file_name'] ?? '')); if ($diskName === '') { throw PageNotFoundException::forPageNotFound(); } $downloadAs = storage_upload_display_name($diskName); return $this->streamAttachment(WRITEPATH . 'uploads/claim_files', $diskName, $downloadAs); } /** * GET /storage/file-download?folder=claim_files&file=xxx.pdf * Streams any storage module object as attachment (never inline). */ public function forceDownloadByFolderFile() { helper('utility'); $folder = basename(str_replace('\\', '/', trim((string) $this->request->getGet('folder')))); $file = basename(str_replace('\\', '/', trim((string) $this->request->getGet('file')))); if ( $folder === '' || $folder === '.' || $folder === '..' || $file === '' || $file === '.' || $file === '..' || preg_match('/[^a-zA-Z0-9_\-]/', $folder) || preg_match('/[\\\\\\/]/', $file) ) { throw PageNotFoundException::forPageNotFound(); } $uploadPath = WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . $folder; $downloadAs = storage_upload_display_name($file); return $this->streamAttachment($uploadPath, $file, $downloadAs); } /** * Stream file bytes with attachment disposition (never inline / never S3 redirect). */ private function streamAttachment(string $uploadPath, string $diskName, string $downloadAs) { $storage = \Config\Services::getFileStorageService(); $result = $storage->download($uploadPath, $diskName); if (! ($result['success'] ?? false)) { throw PageNotFoundException::forPageNotFound(); } if (! empty($result['path']) && is_file($result['path'])) { return $this->response ->download($result['path'], null) ->setFileName($downloadAs); } if (! empty($result['content'])) { return $this->response ->download($downloadAs, $result['content']) ->setFileName($downloadAs); } throw PageNotFoundException::forPageNotFound(); } /** @return list */ private function localUploadFolders(): array { $root = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads'; $out = []; $entries = @scandir($root); if (! is_array($entries)) { return $out; } foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } if (is_dir($root . DIRECTORY_SEPARATOR . $entry)) { $out[] = $entry; } } return $out; } private function formatBytes(int $bytes): string { if ($bytes < 1024) { return $bytes . ' B'; } $units = ['KB', 'MB', 'GB', 'TB']; $value = (float) $bytes; foreach ($units as $unit) { $value /= 1024; if ($value < 1024) { return round($value, 2) . ' ' . $unit; } } return round($value, 2) . ' PB'; } }