nhance/app/Controllers/StorageFilesController.php

288 lines
9.6 KiB
PHP

<?php
namespace App\Controllers;
use App\Models\ClaimFilesModel;
use CodeIgniter\Exceptions\PageNotFoundException;
/**
* Browse S3 upload-bucket objects + file-download (attachment) APIs.
*/
class StorageFilesController extends AdminController
{
private const PER_PAGE = 10;
public function index()
{
$storage = \Config\Services::getFileStorageService(false);
$data = [
'tab_name' => '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<string> */
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';
}
}