288 lines
9.6 KiB
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';
|
|
}
|
|
}
|