197 lines
6.6 KiB
PHP
197 lines
6.6 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers;
|
|
|
|
/**
|
|
* Browse S3 upload-bucket objects folder-wise with paginated search UI.
|
|
*/
|
|
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',
|
|
]);
|
|
}
|
|
|
|
/** @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';
|
|
}
|
|
}
|