FEAT_FILE_UPLOAD_LOCAL_TO_S3
This commit is contained in:
parent
5d6b9058f9
commit
4a350f0822
306
app/Commands/StorageMigrateLocalToS3.php
Normal file
306
app/Commands/StorageMigrateLocalToS3.php
Normal file
@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Services\FileStorageService;
|
||||
use App\Services\Storage\FileStorageException;
|
||||
use App\Services\Storage\S3StorageDriver;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use Config\Storage;
|
||||
|
||||
class StorageMigrateLocalToS3 extends BaseCommand
|
||||
{
|
||||
protected $group = 'Storage';
|
||||
|
||||
protected $name = 'storage:migrate-local-to-s3';
|
||||
|
||||
protected $description = 'Migrate existing local writable/uploads files to S3 (policy & endorsement folders).';
|
||||
|
||||
protected $usage = 'storage:migrate-local-to-s3 [options]';
|
||||
|
||||
protected $options = [
|
||||
'--dry-run' => 'List files that would be migrated without uploading.',
|
||||
'--force' => 'Re-upload even if the object already exists on S3.',
|
||||
'--delete-local' => 'Delete local file after successful S3 upload + verify.',
|
||||
'--verify' => 'Compare MD5 of local file vs S3 read-back after upload (default: on).',
|
||||
'--no-verify' => 'Skip read-back verification after upload.',
|
||||
'--module' => 'Migrate only one module: policy or endorsement.',
|
||||
];
|
||||
|
||||
/** @var array<string, int> */
|
||||
private array $stats = [
|
||||
'scanned' => 0,
|
||||
'uploaded' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
'deleted' => 0,
|
||||
];
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$config = config('Storage');
|
||||
|
||||
if ($config->s3Bucket === '' || $config->s3AccessKey === '' || $config->s3SecretKey === '') {
|
||||
CLI::error('S3 is not configured. Set AWS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY in .env');
|
||||
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
$dryRun = (bool) CLI::getOption('dry-run');
|
||||
$force = (bool) CLI::getOption('force');
|
||||
$deleteLocal = (bool) CLI::getOption('delete-local');
|
||||
$verify = ! CLI::getOption('no-verify');
|
||||
$moduleFilter = CLI::getOption('module');
|
||||
|
||||
if (is_string($moduleFilter) && $moduleFilter !== '' && ! in_array($moduleFilter, ['policy', 'endorsement'], true)) {
|
||||
CLI::error('Invalid --module value. Allowed: policy, endorsement');
|
||||
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
try {
|
||||
$s3Driver = new S3StorageDriver($config);
|
||||
} catch (FileStorageException $e) {
|
||||
CLI::error($e->getMessage());
|
||||
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
$storage = new FileStorageService($config);
|
||||
|
||||
CLI::write('Local to S3 migration', 'green');
|
||||
CLI::write('Local root : ' . $config->localRoot, 'cyan');
|
||||
CLI::write('S3 bucket : ' . $config->s3Bucket, 'cyan');
|
||||
CLI::write('S3 region : ' . $config->s3Region, 'cyan');
|
||||
CLI::write('S3 prefix : ' . ($config->s3Prefix !== '' ? $config->s3Prefix : '(none)'), 'cyan');
|
||||
CLI::write('Mode : ' . ($dryRun ? 'DRY RUN' : 'LIVE'), $dryRun ? 'yellow' : 'green');
|
||||
CLI::newLine();
|
||||
|
||||
$targets = $this->migrationTargets();
|
||||
|
||||
if (is_string($moduleFilter) && $moduleFilter !== '') {
|
||||
$targets = array_values(array_filter(
|
||||
$targets,
|
||||
static fn (array $target) => $target['module'] === $moduleFilter
|
||||
));
|
||||
}
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$this->migrateTarget($target, $s3Driver, $storage, $dryRun, $force, $deleteLocal, $verify);
|
||||
}
|
||||
|
||||
CLI::newLine();
|
||||
CLI::write('Migration summary', 'yellow');
|
||||
CLI::write(' Scanned : ' . $this->stats['scanned'], 'white');
|
||||
CLI::write(' Uploaded: ' . $this->stats['uploaded'], 'green');
|
||||
CLI::write(' Skipped : ' . $this->stats['skipped'], 'yellow');
|
||||
CLI::write(' Failed : ' . $this->stats['failed'], $this->stats['failed'] > 0 ? 'red' : 'white');
|
||||
CLI::write(' Deleted : ' . $this->stats['deleted'], 'white');
|
||||
|
||||
if ($dryRun) {
|
||||
CLI::newLine();
|
||||
CLI::write('Dry run complete. Re-run without --dry-run to upload.', 'yellow');
|
||||
}
|
||||
|
||||
return $this->stats['failed'] === 0 ? EXIT_SUCCESS : EXIT_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{label: string, module: string, subFolder: string, localRelative: string, topLevelOnly: bool}>
|
||||
*/
|
||||
private function migrationTargets(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'label' => 'policy_pdf',
|
||||
'module' => 'policy',
|
||||
'subFolder' => 'policy_pdf',
|
||||
'localRelative' => 'uploads/policy/policy_pdf',
|
||||
'topLevelOnly' => true,
|
||||
],
|
||||
[
|
||||
'label' => 'policy_payment_receipt',
|
||||
'module' => 'policy',
|
||||
'subFolder' => 'policy_payment_receipt',
|
||||
'localRelative' => 'uploads/policy/policy_payment_receipt',
|
||||
'topLevelOnly' => true,
|
||||
],
|
||||
[
|
||||
'label' => 'policy_md',
|
||||
'module' => 'policy',
|
||||
'subFolder' => 'policy_md',
|
||||
'localRelative' => 'uploads/policy/policy_md',
|
||||
'topLevelOnly' => true,
|
||||
],
|
||||
[
|
||||
'label' => 'endorsement_original',
|
||||
'module' => 'endorsement',
|
||||
'subFolder' => '',
|
||||
'localRelative' => 'uploads/endorsement',
|
||||
'topLevelOnly' => true,
|
||||
],
|
||||
[
|
||||
'label' => 'endorsement_completion',
|
||||
'module' => 'endorsement',
|
||||
'subFolder' => 'endorsement_pdf',
|
||||
'localRelative' => 'uploads/endorsement/endorsement_pdf',
|
||||
'topLevelOnly' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{label: string, module: string, subFolder: string, localRelative: string, topLevelOnly: bool} $target
|
||||
*/
|
||||
private function migrateTarget(
|
||||
array $target,
|
||||
S3StorageDriver $s3Driver,
|
||||
FileStorageService $storage,
|
||||
bool $dryRun,
|
||||
bool $force,
|
||||
bool $deleteLocal,
|
||||
bool $verify
|
||||
): void {
|
||||
$localDir = rtrim(config('Storage')->localRoot, '/\\')
|
||||
. DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $target['localRelative']);
|
||||
|
||||
CLI::write('[' . $target['label'] . '] ' . $target['localRelative'], 'yellow');
|
||||
|
||||
if (! is_dir($localDir)) {
|
||||
CLI::write(' Directory not found, skipping.', 'dark_gray');
|
||||
CLI::newLine();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$files = $this->listLocalFiles($localDir, $target['topLevelOnly']);
|
||||
|
||||
if ($files === []) {
|
||||
CLI::write(' No files found.', 'dark_gray');
|
||||
CLI::newLine();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($files as $localPath) {
|
||||
$this->stats['scanned']++;
|
||||
|
||||
$fileName = basename($localPath);
|
||||
$key = $storage->resolveKey($target['module'], $target['subFolder'], $fileName);
|
||||
$size = filesize($localPath) ?: 0;
|
||||
|
||||
try {
|
||||
if (! $force && $s3Driver->exists($key)) {
|
||||
$this->stats['skipped']++;
|
||||
CLI::write(" skip (exists on S3): {$key}", 'dark_gray');
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->stats['uploaded']++;
|
||||
CLI::write(" would upload: {$key} ({$size} bytes)", 'cyan');
|
||||
continue;
|
||||
}
|
||||
|
||||
$s3Driver->upload($localPath, $key);
|
||||
|
||||
if ($verify) {
|
||||
$this->verifyUpload($localPath, $s3Driver, $key);
|
||||
}
|
||||
|
||||
$this->stats['uploaded']++;
|
||||
CLI::write(" uploaded: {$key} ({$size} bytes)", 'green');
|
||||
|
||||
if ($deleteLocal) {
|
||||
if (@unlink($localPath)) {
|
||||
$this->stats['deleted']++;
|
||||
CLI::write(' deleted local: ' . $localPath, 'dark_gray');
|
||||
} else {
|
||||
throw new FileStorageException('Uploaded to S3 but failed to delete local file: ' . $localPath);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->stats['failed']++;
|
||||
CLI::error(" failed: {$key} -> " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
CLI::newLine();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> absolute local file paths
|
||||
*/
|
||||
private function listLocalFiles(string $directory, bool $topLevelOnly): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
if ($topLevelOnly) {
|
||||
foreach (scandir($directory) ?: [] as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $directory . DIRECTORY_SEPARATOR . $item;
|
||||
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->shouldSkipFile($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = $path;
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if (! $fileInfo->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $fileInfo->getFilename();
|
||||
|
||||
if ($this->shouldSkipFile($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = $fileInfo->getPathname();
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function shouldSkipFile(string $fileName): bool
|
||||
{
|
||||
return in_array(strtolower($fileName), ['index.html', '.htaccess', '.gitkeep'], true);
|
||||
}
|
||||
|
||||
private function verifyUpload(string $localPath, S3StorageDriver $s3Driver, string $key): void
|
||||
{
|
||||
$localHash = md5_file($localPath);
|
||||
|
||||
if ($localHash === false) {
|
||||
throw new FileStorageException('Unable to hash local file: ' . $localPath);
|
||||
}
|
||||
|
||||
$remoteContents = $s3Driver->read($key);
|
||||
$remoteHash = md5($remoteContents);
|
||||
|
||||
if ($localHash !== $remoteHash) {
|
||||
throw new FileStorageException('Verification failed: S3 content hash mismatch for ' . $key);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
app/Commands/StorageTest.php
Normal file
93
app/Commands/StorageTest.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
class StorageTest extends BaseCommand
|
||||
{
|
||||
protected $group = 'Testing';
|
||||
|
||||
protected $name = 'storage:test';
|
||||
|
||||
protected $description = 'Run policy/endorsement storage tests (local driver and optional live S3).';
|
||||
|
||||
protected $usage = 'storage:test [options]';
|
||||
|
||||
protected $options = [
|
||||
'--local' => 'Run local/helper tests only (skip live S3 integration group).',
|
||||
'--s3' => 'Run live S3 integration tests only (requires AWS env in .env).',
|
||||
'--filter' => 'PHPUnit filter pattern, e.g. PolicyStorageHelper.',
|
||||
];
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$phpunit = ROOTPATH . 'vendor/bin/phpunit';
|
||||
$phpunitConfig = ROOTPATH . 'phpunit.xml.dist';
|
||||
$testsPath = ROOTPATH . 'tests/unit/Storage';
|
||||
|
||||
if (! is_file($phpunit)) {
|
||||
CLI::error('PHPUnit not found. Run: composer install');
|
||||
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
if (! is_file($phpunitConfig)) {
|
||||
CLI::error('PHPUnit config not found: ' . $phpunitConfig);
|
||||
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
if (! is_dir($testsPath)) {
|
||||
CLI::error('Storage tests directory not found: ' . $testsPath);
|
||||
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
$previousDirectory = getcwd() ?: ROOTPATH;
|
||||
chdir(ROOTPATH);
|
||||
|
||||
$command = [
|
||||
PHP_BINARY,
|
||||
$phpunit,
|
||||
'-c',
|
||||
$phpunitConfig,
|
||||
$testsPath,
|
||||
'--testdox',
|
||||
'--colors=always',
|
||||
'--no-coverage',
|
||||
];
|
||||
|
||||
if (CLI::getOption('local')) {
|
||||
$command[] = '--exclude-group';
|
||||
$command[] = 's3-integration';
|
||||
} elseif (CLI::getOption('s3')) {
|
||||
$command[] = '--group';
|
||||
$command[] = 's3-integration';
|
||||
}
|
||||
|
||||
$filter = CLI::getOption('filter');
|
||||
|
||||
if (is_string($filter) && $filter !== '') {
|
||||
$command[] = '--filter';
|
||||
$command[] = $filter;
|
||||
}
|
||||
|
||||
$display = implode(' ', array_map(static fn ($part) => escapeshellarg((string) $part), $command));
|
||||
CLI::write('Running storage tests...', 'green');
|
||||
CLI::write($display, 'dark_gray');
|
||||
|
||||
passthru($display, $exitCode);
|
||||
|
||||
chdir($previousDirectory);
|
||||
|
||||
if ($exitCode === 0) {
|
||||
CLI::write('Storage tests passed.', 'green');
|
||||
} else {
|
||||
CLI::error('Storage tests failed with exit code ' . $exitCode);
|
||||
}
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
}
|
||||
300
app/Commands/StorageUploadTest.php
Normal file
300
app/Commands/StorageUploadTest.php
Normal file
@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Services\Storage\FileStorageException;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use Config\Storage;
|
||||
|
||||
class StorageUploadTest extends BaseCommand
|
||||
{
|
||||
protected $group = 'Testing';
|
||||
|
||||
protected $name = 'storage:upload-test';
|
||||
|
||||
protected $description = 'Run a live file upload/download/delete check against configured storage (local or S3).';
|
||||
|
||||
protected $usage = 'storage:upload-test [options]';
|
||||
|
||||
protected $options = [
|
||||
'--keep' => 'Keep uploaded test files in storage (default: delete after success).',
|
||||
'--pdf' => 'Use this PDF file path for policy_pdf upload instead of a generated sample.',
|
||||
];
|
||||
|
||||
/** @var list<array{module: string, subFolder: string, fileName: string}> */
|
||||
private array $uploadedObjects = [];
|
||||
|
||||
private string $tempDir = '';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
helper('storage_helper');
|
||||
|
||||
$config = config('Storage');
|
||||
$this->printEnvironment($config);
|
||||
|
||||
$this->tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'nhance_storage_upload_test_' . uniqid('', true);
|
||||
if (! mkdir($this->tempDir, 0777, true) && ! is_dir($this->tempDir)) {
|
||||
CLI::error('Unable to create temp directory: ' . $this->tempDir);
|
||||
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
$keepFiles = (bool) CLI::getOption('keep');
|
||||
$failed = 0;
|
||||
$passed = 0;
|
||||
|
||||
CLI::newLine();
|
||||
CLI::write('Starting live storage upload scenarios...', 'yellow');
|
||||
CLI::newLine();
|
||||
|
||||
$scenarios = [
|
||||
'policy_pdf' => fn () => $this->scenarioPolicyPdf(),
|
||||
'policy_payment_receipt' => fn () => $this->scenarioPolicyReceipt(),
|
||||
'policy_md' => fn () => $this->scenarioPolicyMarkdown(),
|
||||
'endorsement_original' => fn () => $this->scenarioEndorsementOriginal(),
|
||||
'endorsement_completion' => fn () => $this->scenarioEndorsementCompletion(),
|
||||
];
|
||||
|
||||
foreach ($scenarios as $label => $callback) {
|
||||
try {
|
||||
$callback();
|
||||
CLI::write('[PASS] ' . $label, 'green');
|
||||
$passed++;
|
||||
} catch (\Throwable $e) {
|
||||
CLI::error('[FAIL] ' . $label . ' -> ' . $e->getMessage());
|
||||
$failed++;
|
||||
}
|
||||
|
||||
CLI::newLine();
|
||||
}
|
||||
|
||||
if (! $keepFiles && $failed === 0) {
|
||||
$this->cleanupUploadedObjects();
|
||||
CLI::write('Cleaned up uploaded test files from storage.', 'dark_gray');
|
||||
} elseif ($keepFiles) {
|
||||
CLI::write('Uploaded test files were kept (--keep).', 'yellow');
|
||||
foreach ($this->uploadedObjects as $object) {
|
||||
CLI::write(
|
||||
' - ' . storage()->resolveKey($object['module'], $object['subFolder'], $object['fileName']),
|
||||
'white'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->cleanupTempDir();
|
||||
|
||||
CLI::newLine();
|
||||
CLI::write("Summary: {$passed} passed, {$failed} failed", $failed === 0 ? 'green' : 'red');
|
||||
|
||||
return $failed === 0 ? EXIT_SUCCESS : EXIT_ERROR;
|
||||
}
|
||||
|
||||
private function printEnvironment(Storage $config): void
|
||||
{
|
||||
CLI::write('Storage upload test', 'green');
|
||||
CLI::write('Driver: ' . ($config->driver === 's3' ? 's3' : 'local'), 'cyan');
|
||||
|
||||
if ($config->driver === 's3') {
|
||||
CLI::write('Bucket: ' . ($config->s3Bucket ?: '(empty)'), 'cyan');
|
||||
CLI::write('Region: ' . $config->s3Region, 'cyan');
|
||||
CLI::write('Prefix: ' . ($config->s3Prefix !== '' ? $config->s3Prefix : '(none)'), 'cyan');
|
||||
} else {
|
||||
CLI::write('Local root: ' . $config->localRoot, 'cyan');
|
||||
}
|
||||
}
|
||||
|
||||
private function scenarioPolicyPdf(): void
|
||||
{
|
||||
$customPdf = CLI::getOption('pdf');
|
||||
$source = is_string($customPdf) && $customPdf !== '' && is_file($customPdf)
|
||||
? $customPdf
|
||||
: $this->createSamplePdf();
|
||||
|
||||
$fileName = time() . '_spark_test_policy.pdf';
|
||||
$content = file_get_contents($source);
|
||||
|
||||
if ($content === false || $content === '') {
|
||||
throw new FileStorageException('Sample PDF is empty or unreadable');
|
||||
}
|
||||
|
||||
$this->uploadFile('policy', 'policy_pdf', $fileName, $source, $content);
|
||||
$this->assertReadable('policy', 'policy_pdf', $fileName, $content);
|
||||
$this->assertLocalProcessingPath('policy', 'policy_pdf', $fileName);
|
||||
$this->assertTemporaryUrl('policy', 'policy_pdf', $fileName);
|
||||
}
|
||||
|
||||
private function scenarioPolicyReceipt(): void
|
||||
{
|
||||
$source = $this->createTempFile('sample_receipt.jpg', 'JPEG receipt test content for spark upload-test');
|
||||
$fileName = time() . '_spark_test_receipt.jpg';
|
||||
$content = file_get_contents($source);
|
||||
|
||||
$this->uploadFile('policy', 'policy_payment_receipt', $fileName, $source, $content);
|
||||
$this->assertReadable('policy', 'policy_payment_receipt', $fileName, $content);
|
||||
$this->assertTemporaryUrl('policy', 'policy_payment_receipt', $fileName);
|
||||
}
|
||||
|
||||
private function scenarioPolicyMarkdown(): void
|
||||
{
|
||||
$fileName = time() . '_spark_test_policy.md';
|
||||
$content = "# Spark Storage Test\n\n" . str_repeat('Policy markdown body. ', 40);
|
||||
|
||||
storage_put('policy', 'policy_md', $fileName, $content, ['content_type' => 'text/markdown']);
|
||||
$this->trackUploaded('policy', 'policy_md', $fileName);
|
||||
|
||||
if (! policy_md_exists($fileName)) {
|
||||
throw new FileStorageException('policy_md file not found after upload');
|
||||
}
|
||||
|
||||
if (policy_read_md($fileName) !== $content) {
|
||||
throw new FileStorageException('policy_md content mismatch after read');
|
||||
}
|
||||
}
|
||||
|
||||
private function scenarioEndorsementOriginal(): void
|
||||
{
|
||||
$source = $this->createTempFile('endorsement_original.pdf', '%PDF-1.4 endorsement original test');
|
||||
$fileName = time() . '_spark_test_endorsement.pdf';
|
||||
$content = file_get_contents($source);
|
||||
|
||||
$this->uploadFile('endorsement', '', $fileName, $source, $content);
|
||||
$this->assertReadable('endorsement', '', $fileName, $content);
|
||||
$this->assertTemporaryUrl('endorsement', '', $fileName);
|
||||
}
|
||||
|
||||
private function scenarioEndorsementCompletion(): void
|
||||
{
|
||||
$source = $this->createTempFile('endorsement_completion.pdf', '%PDF-1.4 endorsement completion test');
|
||||
$fileName = time() . '_spark_test_completion.pdf';
|
||||
$content = file_get_contents($source);
|
||||
|
||||
$this->uploadFile('endorsement', 'endorsement_pdf', $fileName, $source, $content);
|
||||
$this->assertReadable('endorsement', 'endorsement_pdf', $fileName, $content);
|
||||
$this->assertTemporaryUrl('endorsement', 'endorsement_pdf', $fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|false $content
|
||||
*/
|
||||
private function uploadFile(
|
||||
string $module,
|
||||
string $subFolder,
|
||||
string $fileName,
|
||||
string $sourcePath,
|
||||
$content
|
||||
): void {
|
||||
storage()->upload($sourcePath, storage()->resolveKey($module, $subFolder, $fileName));
|
||||
$this->trackUploaded($module, $subFolder, $fileName);
|
||||
|
||||
if (! storage_exists($module, $subFolder, $fileName)) {
|
||||
throw new FileStorageException('File not found immediately after upload');
|
||||
}
|
||||
|
||||
if ($content !== false && storage_read($module, $subFolder, $fileName) !== $content) {
|
||||
throw new FileStorageException('Uploaded file content mismatch on read-back');
|
||||
}
|
||||
|
||||
CLI::write(' uploaded: ' . storage()->resolveKey($module, $subFolder, $fileName), 'white');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|false $expectedContent
|
||||
*/
|
||||
private function assertReadable(string $module, string $subFolder, string $fileName, $expectedContent): void
|
||||
{
|
||||
if (! storage_exists($module, $subFolder, $fileName)) {
|
||||
throw new FileStorageException('exists() returned false');
|
||||
}
|
||||
|
||||
$readBack = storage_read($module, $subFolder, $fileName);
|
||||
|
||||
if ($expectedContent !== false && $readBack !== $expectedContent) {
|
||||
throw new FileStorageException('read() content mismatch');
|
||||
}
|
||||
|
||||
CLI::write(' read-back: OK (' . strlen($readBack) . ' bytes)', 'white');
|
||||
}
|
||||
|
||||
private function assertLocalProcessingPath(string $module, string $subFolder, string $fileName): void
|
||||
{
|
||||
$path = storage_local_path($module, $subFolder, $fileName);
|
||||
|
||||
if (! is_file($path)) {
|
||||
throw new FileStorageException('Local processing path not readable: ' . $path);
|
||||
}
|
||||
|
||||
CLI::write(' local path: ' . $path, 'white');
|
||||
}
|
||||
|
||||
private function assertTemporaryUrl(string $module, string $subFolder, string $fileName): void
|
||||
{
|
||||
$url = storage_temporary_url($module, $subFolder, $fileName);
|
||||
|
||||
if ($url === '') {
|
||||
throw new FileStorageException('temporary URL is empty');
|
||||
}
|
||||
|
||||
CLI::write(' url: ' . $url, 'white');
|
||||
}
|
||||
|
||||
private function trackUploaded(string $module, string $subFolder, string $fileName): void
|
||||
{
|
||||
$this->uploadedObjects[] = [
|
||||
'module' => $module,
|
||||
'subFolder' => $subFolder,
|
||||
'fileName' => $fileName,
|
||||
];
|
||||
}
|
||||
|
||||
private function cleanupUploadedObjects(): void
|
||||
{
|
||||
foreach ($this->uploadedObjects as $object) {
|
||||
try {
|
||||
storage_delete($object['module'], $object['subFolder'], $object['fileName']);
|
||||
} catch (\Throwable $e) {
|
||||
CLI::write(
|
||||
' cleanup warning: ' . $object['fileName'] . ' -> ' . $e->getMessage(),
|
||||
'yellow'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupTempDir(): void
|
||||
{
|
||||
if ($this->tempDir === '' || ! is_dir($this->tempDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (scandir($this->tempDir) ?: [] as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@unlink($this->tempDir . DIRECTORY_SEPARATOR . $item);
|
||||
}
|
||||
|
||||
@rmdir($this->tempDir);
|
||||
}
|
||||
|
||||
private function createSamplePdf(): string
|
||||
{
|
||||
return $this->createTempFile(
|
||||
'sample_policy.pdf',
|
||||
"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \ntrailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n110\n%%EOF\n"
|
||||
);
|
||||
}
|
||||
|
||||
private function createTempFile(string $name, string $contents): string
|
||||
{
|
||||
$path = $this->tempDir . DIRECTORY_SEPARATOR . $name;
|
||||
|
||||
if (file_put_contents($path, $contents) === false) {
|
||||
throw new FileStorageException('Failed to create temp file: ' . $path);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@ -90,5 +90,5 @@ class Autoload extends AutoloadConfig
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public $helpers = ['JwtHelper','common_helper','mail_helper','url_helper','status_helper','sms_helper'];
|
||||
public $helpers = ['JwtHelper','common_helper','mail_helper','url_helper','status_helper','sms_helper','storage_helper'];
|
||||
}
|
||||
|
||||
@ -269,7 +269,13 @@ $routes->cli('cli/processjob', 'JobWorker::processJob');
|
||||
$routes->cli('cli/processjobs', 'JobWorker::processJobs');
|
||||
$routes->get("processjob", "JobWorker::processJob");
|
||||
|
||||
|
||||
$routes->group('storage/browser', static function ($routes) {
|
||||
$routes->get('/', 'StorageBrowserController::index');
|
||||
$routes->get('folders', 'StorageBrowserController::folders');
|
||||
$routes->get('files', 'StorageBrowserController::files');
|
||||
$routes->get('open', 'StorageBrowserController::open');
|
||||
$routes->get('download', 'StorageBrowserController::download');
|
||||
});
|
||||
|
||||
|
||||
$routes->get('checkPolicyDoc', 'PolicyController::readFile');
|
||||
|
||||
@ -19,14 +19,21 @@ use CodeIgniter\Config\BaseService;
|
||||
*/
|
||||
class Services extends BaseService
|
||||
{
|
||||
/*
|
||||
* public static function example($getShared = true)
|
||||
* {
|
||||
* if ($getShared) {
|
||||
* return static::getSharedInstance('example');
|
||||
* }
|
||||
*
|
||||
* return new \CodeIgniter\Example();
|
||||
* }
|
||||
*/
|
||||
public static function fileStorage($getShared = true)
|
||||
{
|
||||
if ($getShared) {
|
||||
return static::getSharedInstance('fileStorage');
|
||||
}
|
||||
|
||||
return new \App\Services\FileStorageService(config('Storage'));
|
||||
}
|
||||
|
||||
public static function storageBrowser($getShared = true)
|
||||
{
|
||||
if ($getShared) {
|
||||
return static::getSharedInstance('storageBrowser');
|
||||
}
|
||||
|
||||
return new \App\Services\StorageBrowserService(config('Storage'));
|
||||
}
|
||||
}
|
||||
|
||||
52
app/Config/Storage.php
Normal file
52
app/Config/Storage.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
class Storage extends BaseConfig
|
||||
{
|
||||
/** @var 'local'|'s3' */
|
||||
public string $driver = 'local';
|
||||
|
||||
/** When using S3, also write a copy to local disk (migration / fallback). */
|
||||
public bool $retainLocal = false;
|
||||
|
||||
public string $localRoot;
|
||||
|
||||
public string $s3Bucket = '';
|
||||
|
||||
public string $s3Region = 'ap-south-1';
|
||||
|
||||
/** Optional key prefix, e.g. uat or prod */
|
||||
public string $s3Prefix = '';
|
||||
|
||||
public string $s3AccessKey = '';
|
||||
|
||||
public string $s3SecretKey = '';
|
||||
|
||||
/** Default presigned URL TTL in seconds. */
|
||||
public int $presignedTtl = 900;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$driver = getenv('FILE_STORAGE_DRIVER') ?: 'local';
|
||||
$this->driver = in_array($driver, ['local', 's3'], true) ? $driver : 'local';
|
||||
|
||||
$this->retainLocal = filter_var(getenv('RETAIN_LOCAL') ?: 'false', FILTER_VALIDATE_BOOLEAN);
|
||||
$this->localRoot = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR;
|
||||
|
||||
$this->s3Bucket = getenv('AWS_BUCKET') ?: '';
|
||||
$this->s3Region = getenv('AWS_DEFAULT_REGION') ?: 'ap-south-1';
|
||||
$this->s3Prefix = trim(getenv('AWS_S3_PREFIX') ?: '', '/');
|
||||
$this->s3AccessKey = getenv('AWS_ACCESS_KEY_ID') ?: '';
|
||||
$this->s3SecretKey = getenv('AWS_SECRET_ACCESS_KEY') ?: '';
|
||||
|
||||
$ttl = getenv('AWS_PRESIGNED_TTL');
|
||||
if ($ttl !== false && $ttl !== '' && is_numeric($ttl)) {
|
||||
$this->presignedTtl = max(60, (int) $ttl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -192,19 +192,7 @@ class EndorsementController extends ResourceController
|
||||
}
|
||||
|
||||
//FILE UPLOAD
|
||||
$uploadedFileName = null;
|
||||
$uploadFile = $this->request->getFile('endorsement_file_name');
|
||||
|
||||
if ($uploadFile && $uploadFile->isValid()) {
|
||||
|
||||
$uploadPath = WRITEPATH . 'uploads/endorsement/';
|
||||
if (!is_dir($uploadPath)) {
|
||||
mkdir($uploadPath, 0777, true);
|
||||
}
|
||||
|
||||
$uploadedFileName = time() . '_' . $uploadFile->getRandomName();
|
||||
$uploadFile->move($uploadPath, $uploadedFileName);
|
||||
}
|
||||
$uploadedFileName = endorsement_upload_original($this->request->getFile('endorsement_file_name'));
|
||||
|
||||
// PREPARE DATA
|
||||
if ($reqData['policy_from'] === 'Internal') {
|
||||
@ -288,33 +276,12 @@ class EndorsementController extends ResourceController
|
||||
if (!$endorsement) { return $this->respond([ 'status' => 'failed', 'code' => 404,'data' => 'Endorsement not found' ], 404); }
|
||||
|
||||
// FILE UPLOAD
|
||||
$uploadedOriginalCompletionFile = $endorsement['endorsement_file_name'];
|
||||
|
||||
$uploadOriginalFile = $this->request->getFile('endorsement_file_name');
|
||||
|
||||
if ($uploadOriginalFile && $uploadOriginalFile->isValid()) {
|
||||
|
||||
$uploadOriginalPath = WRITEPATH . 'uploads/endorsement/';
|
||||
if (!is_dir($uploadOriginalPath)) {
|
||||
mkdir($uploadOriginalPath, 0777, true);
|
||||
}
|
||||
|
||||
$uploadedOriginalCompletionFile = time() . '_' . $uploadOriginalFile->getRandomName();
|
||||
$uploadOriginalFile->move($uploadOriginalPath, $uploadedOriginalCompletionFile); // ✅ fixed variable
|
||||
}
|
||||
$uploadedOriginalCompletionFile = endorsement_upload_original($this->request->getFile('endorsement_file_name'))
|
||||
?? $endorsement['endorsement_file_name'];
|
||||
|
||||
// FILE REVISED UPLOAD
|
||||
$uploadedRevisedCompletionFile = $endorsement['endorsement_completion_file'];
|
||||
$uploadRevisedFile = $this->request->getFile('endorsement_completion_file');
|
||||
|
||||
if ($uploadRevisedFile && $uploadRevisedFile->isValid()) {
|
||||
$uploadRevisedPath = WRITEPATH . 'uploads/endorsement/endorsement_pdf/';
|
||||
if (!is_dir($uploadRevisedPath)) {
|
||||
mkdir($uploadRevisedPath, 0777, true);
|
||||
}
|
||||
$uploadedRevisedCompletionFile = time() . '_' . $uploadRevisedFile->getRandomName();
|
||||
$uploadRevisedFile->move($uploadRevisedPath, $uploadedRevisedCompletionFile);
|
||||
}
|
||||
$uploadedRevisedCompletionFile = endorsement_upload_completion($this->request->getFile('endorsement_completion_file'))
|
||||
?? $endorsement['endorsement_completion_file'];
|
||||
|
||||
/*
|
||||
* COMMON UPDATE FIELDS
|
||||
@ -671,14 +638,11 @@ class EndorsementController extends ResourceController
|
||||
return $this->respond(['status' => 'failed', 'code' => 404, 'data' => 'No file uploaded yet'], 200);
|
||||
}
|
||||
|
||||
$filePath = WRITEPATH . 'uploads/endorsement/' . $subPath . $fileName;
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
if (!endorsement_exists_by_type($fileName, $type ?: 'original')) {
|
||||
return $this->respond(['status' => 'failed', 'code' => 404, 'data' => 'File missing on server'], 200);
|
||||
}
|
||||
|
||||
// Force file download
|
||||
return $this->response->download($filePath, null);
|
||||
return endorsement_download_by_type($fileName, $type ?: 'original');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return $this->respond(['status' => 'failed', 'code' => 500, 'message' => $e->getMessage()], 500);
|
||||
@ -747,30 +711,20 @@ class EndorsementController extends ResourceController
|
||||
$originalFile = $fileRecord['endorsement_file_name'] ?? null;
|
||||
|
||||
if (!empty($completionFile)) {
|
||||
// ✅ Use completion file path
|
||||
$fileName = $completionFile;
|
||||
$filePath = WRITEPATH . 'uploads/endorsement/endorsement_pdf/' . $fileName;
|
||||
$fileType = 'completion';
|
||||
} elseif (!empty($originalFile)) {
|
||||
// ✅ Fallback to original file path
|
||||
$fileName = $originalFile;
|
||||
$filePath = WRITEPATH . 'uploads/endorsement/' . $fileName;
|
||||
$fileType = 'original';
|
||||
} else {
|
||||
// ❌ Neither file exists in DB
|
||||
return $this->respond(['status' => 'failed', 'code' => 404, 'data' => 'No file uploaded yet'], 200);
|
||||
}
|
||||
|
||||
// ✅ STEP 2: Check file exists on disk
|
||||
if (!file_exists($filePath)) {
|
||||
if (!endorsement_exists_by_type($fileName, $fileType)) {
|
||||
return $this->respond(['status' => 'failed', 'code' => 404, 'data' => 'File missing on server'], 200);
|
||||
}
|
||||
|
||||
// ✅ STEP 3: Stream file to browser / Flutter
|
||||
$mime = mime_content_type($filePath);
|
||||
|
||||
return $this->response
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $fileName . '"')
|
||||
->setBody(file_get_contents($filePath));
|
||||
return endorsement_download_by_type($fileName, $fileType, true);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return $this->respond(['status' => 'failed', 'code' => 500, 'message' => $e->getMessage()], 500);
|
||||
@ -787,9 +741,7 @@ class EndorsementController extends ResourceController
|
||||
return $this->respond(['status' => 'failed', 'code' => 400, 'data' => 'Missing required fields'], 400);
|
||||
}
|
||||
|
||||
$uploadPath = WRITEPATH . 'uploads/endorsement/';
|
||||
$allowedTypes = ['application/pdf'];
|
||||
$pdfPath = $uploadPath . 'endorsement_pdf/';
|
||||
|
||||
$endorsementPdf = $this->request->getFile('endorsement_completion_file');
|
||||
|
||||
@ -807,17 +759,9 @@ class EndorsementController extends ResourceController
|
||||
$existingRecord = $this->EndorsementModel->find($data['id']);
|
||||
$oldFileName = $existingRecord['endorsement_completion_file'] ?? null;
|
||||
|
||||
// ✅ Ensure upload directory exists
|
||||
if (!is_dir($pdfPath)) {
|
||||
mkdir($pdfPath, 0777, true);
|
||||
}
|
||||
$pdfFileName = endorsement_upload_completion($endorsementPdf);
|
||||
|
||||
// ✅ Upload new file
|
||||
$pdfFileName = time() . '_' . $endorsementPdf->getRandomName();
|
||||
$endorsementPdf->move($pdfPath, $pdfFileName);
|
||||
|
||||
// ✅ Guard: ensure file was actually saved on disk
|
||||
if (!$pdfFileName || !file_exists($pdfPath . $pdfFileName)) {
|
||||
if (!$pdfFileName || !endorsement_exists_by_type($pdfFileName, 'completion')) {
|
||||
return $this->respond(['status' => 'failed', 'code' => 400, 'data' => 'File upload failed'], 400);
|
||||
}
|
||||
|
||||
@ -831,22 +775,17 @@ class EndorsementController extends ResourceController
|
||||
|
||||
// ✅ DB update failed → delete the newly uploaded file to avoid orphan files
|
||||
if (!$updated) {
|
||||
if (file_exists($pdfPath . $pdfFileName)) {
|
||||
unlink($pdfPath . $pdfFileName);
|
||||
}
|
||||
endorsement_delete_completion($pdfFileName);
|
||||
log_message('error', 'DB update failed for endorsement ID: ' . $data['id'] . '. New file removed.');
|
||||
return $this->respond(['status' => 'failed', 'code' => 500, 'data' => 'DB update failed, file not saved'], 500);
|
||||
}
|
||||
|
||||
// ✅ DB success → NOW safe to delete old file
|
||||
if ($oldFileName) {
|
||||
$oldFilePath = $pdfPath . $oldFileName;
|
||||
if (file_exists($oldFilePath)) {
|
||||
if (!unlink($oldFilePath)) {
|
||||
log_message('warning', 'DB updated but failed to delete old file: ' . $oldFilePath);
|
||||
} else {
|
||||
log_message('info', 'Old file deleted after successful DB update: ' . $oldFilePath);
|
||||
}
|
||||
if (!endorsement_delete_completion($oldFileName)) {
|
||||
log_message('warning', 'DB updated but failed to delete old file: ' . $oldFileName);
|
||||
} else {
|
||||
log_message('info', 'Old file deleted after successful DB update: ' . $oldFileName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -125,33 +125,9 @@ class PolicyController extends ResourceController
|
||||
{
|
||||
try {
|
||||
$data = $this->request->getPost();
|
||||
$uploadPath = WRITEPATH . 'uploads/policy/';
|
||||
|
||||
$policyPdf = $this->request->getFile('policy_pdf_file_name');
|
||||
$policyReceipt = $this->request->getFile('policy_payment_receipt_file_name');
|
||||
|
||||
$pdfFileName = null;
|
||||
$receiptFileName = null;
|
||||
|
||||
// PDF Upload
|
||||
if ($policyPdf && $policyPdf->isValid()) {
|
||||
$pdfPath = $uploadPath . 'policy_pdf/';
|
||||
if (!is_dir($pdfPath)) {
|
||||
mkdir($pdfPath, 0777, true);
|
||||
}
|
||||
$pdfFileName = time() . '_' . $policyPdf->getRandomName();
|
||||
$policyPdf->move($pdfPath, $pdfFileName);
|
||||
}
|
||||
|
||||
// Receipt Upload
|
||||
if ($policyReceipt && $policyReceipt->isValid()) {
|
||||
$receiptPath = $uploadPath . 'policy_payment_receipt/';
|
||||
if (!is_dir($receiptPath)) {
|
||||
mkdir($receiptPath, 0777, true);
|
||||
}
|
||||
$receiptFileName = time() . '_' . $policyReceipt->getRandomName();
|
||||
$policyReceipt->move($receiptPath, $receiptFileName);
|
||||
}
|
||||
$pdfFileName = policy_upload_pdf($this->request->getFile('policy_pdf_file_name'));
|
||||
$receiptFileName = policy_upload_receipt($this->request->getFile('policy_payment_receipt_file_name'));
|
||||
|
||||
//fetch enquiry_id & agent_id
|
||||
$quotData = $this->QuotationModel->select('partner_quotation.*,E.agent_id')
|
||||
@ -359,30 +335,14 @@ class PolicyController extends ResourceController
|
||||
}
|
||||
|
||||
|
||||
$uploadPath = WRITEPATH . 'uploads/policy/';
|
||||
|
||||
// PDF Update
|
||||
$policyPdf = $this->request->getFile('policy_pdf_file_name');
|
||||
if ($policyPdf && $policyPdf->isValid()) {
|
||||
$pdfPath = $uploadPath . 'policy_pdf/';
|
||||
if (!is_dir($pdfPath)) {
|
||||
mkdir($pdfPath, 0777, true);
|
||||
}
|
||||
$pdfFileName = time() . '_' . $policyPdf->getRandomName();
|
||||
$policyPdf->move($pdfPath, $pdfFileName);
|
||||
$updateData['policy_pdf_file_name'] = $pdfFileName;
|
||||
$policyPdf = policy_upload_pdf($this->request->getFile('policy_pdf_file_name'));
|
||||
if ($policyPdf !== null) {
|
||||
$updateData['policy_pdf_file_name'] = $policyPdf;
|
||||
}
|
||||
|
||||
// Receipt Update
|
||||
$policyReceipt = $this->request->getFile('policy_payment_receipt_file_name');
|
||||
if ($policyReceipt && $policyReceipt->isValid()) {
|
||||
$receiptPath = $uploadPath . 'policy_payment_receipt/';
|
||||
if (!is_dir($receiptPath)) {
|
||||
mkdir($receiptPath, 0777, true);
|
||||
}
|
||||
$receiptFileName = time() . '_' . $policyReceipt->getRandomName();
|
||||
$policyReceipt->move($receiptPath, $receiptFileName);
|
||||
$updateData['policy_payment_receipt_file_name'] = $receiptFileName;
|
||||
$policyReceipt = policy_upload_receipt($this->request->getFile('policy_payment_receipt_file_name'));
|
||||
if ($policyReceipt !== null) {
|
||||
$updateData['policy_payment_receipt_file_name'] = $policyReceipt;
|
||||
}
|
||||
|
||||
$this->PolicyModel->update($id, $updateData);
|
||||
@ -494,21 +454,8 @@ class PolicyController extends ResourceController
|
||||
try {
|
||||
|
||||
$data = $this->request->getPost();
|
||||
$uploadPath = WRITEPATH . 'uploads/policy/';
|
||||
|
||||
$policyPdf = $this->request->getFile('policy_pdf_file_name');
|
||||
|
||||
$pdfFileName = null;
|
||||
|
||||
// PDF Upload
|
||||
if ($policyPdf && $policyPdf->isValid()) {
|
||||
$pdfPath = $uploadPath . 'policy_pdf/';
|
||||
if (!is_dir($pdfPath)) {
|
||||
mkdir($pdfPath, 0777, true);
|
||||
}
|
||||
$pdfFileName = time() . '_' . $policyPdf->getRandomName();
|
||||
$policyPdf->move($pdfPath, $pdfFileName);
|
||||
}
|
||||
$pdfFileName = policy_upload_pdf($this->request->getFile('policy_pdf_file_name'));
|
||||
|
||||
//fetch enquiry_id & agent_id
|
||||
$quotData = $this->QuotationModel->select('partner_quotation.*,E.agent_id,E.name , VT.vehicle_type')
|
||||
@ -606,17 +553,13 @@ class PolicyController extends ResourceController
|
||||
}
|
||||
|
||||
// Map file_type to DB column and folder
|
||||
$fileMap = [
|
||||
'policy_pdf' => ['column' => 'policy_pdf_file_name', 'folder' => 'policy_pdf'],
|
||||
'policy_payment_receipt'=> ['column' => 'policy_payment_receipt_file_name', 'folder' => 'policy_payment_receipt'],
|
||||
];
|
||||
$fileMap = policy_file_type_map();
|
||||
|
||||
if (!array_key_exists($fileType, $fileMap)) {
|
||||
return $this->respond(['status' => 'failed','code' => 400,'data' => 'Invalid file_type'], 200);
|
||||
}
|
||||
|
||||
$fileColumn = $fileMap[$fileType]['column'];
|
||||
$folder = $fileMap[$fileType]['folder'];
|
||||
|
||||
// Fetch record from DB
|
||||
$fileRecord = $this->PolicyModel->where('is_active', 1)->find((int)$policyId);
|
||||
@ -625,14 +568,11 @@ class PolicyController extends ResourceController
|
||||
return $this->respond([ 'status' => 'failed','code' => 404,'data' => 'File not found in database'], 200);
|
||||
}
|
||||
|
||||
$filePath = WRITEPATH . "uploads/policy/{$folder}/" . $fileRecord[$fileColumn];
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
if (!policy_exists_by_type($fileRecord[$fileColumn], $fileType)) {
|
||||
return $this->respond(['status' => 'failed','code' => 404,'data' => 'File missing on server'], 200);
|
||||
}
|
||||
|
||||
// Force file download
|
||||
return $this->response->download($filePath, null);
|
||||
return policy_download_by_type($fileRecord[$fileColumn], $fileType);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return $this->respond(['status' => 'failed','code' => 500,'message' => $e->getMessage() ], 500);
|
||||
@ -650,17 +590,13 @@ class PolicyController extends ResourceController
|
||||
}
|
||||
|
||||
// Map file_type to DB column and folder
|
||||
$fileMap = [
|
||||
'policy_pdf' => ['column' => 'policy_pdf_file_name', 'folder' => 'policy_pdf'],
|
||||
'policy_payment_receipt'=> ['column' => 'policy_payment_receipt_file_name', 'folder' => 'policy_payment_receipt'],
|
||||
];
|
||||
$fileMap = policy_file_type_map();
|
||||
|
||||
if (!array_key_exists($fileType, $fileMap)) {
|
||||
return $this->respond(['status' => 'failed','code' => 400,'data' => 'Invalid file_type'], 200);
|
||||
}
|
||||
|
||||
$fileColumn = $fileMap[$fileType]['column'];
|
||||
$folder = $fileMap[$fileType]['folder'];
|
||||
|
||||
// Fetch record from DB
|
||||
$fileRecord = $this->PolicyModel->where('is_active', 1)->find((int)$policyId);
|
||||
@ -669,24 +605,11 @@ class PolicyController extends ResourceController
|
||||
return $this->respond([ 'status' => 'failed','code' => 404,'data' => 'File not found in database'], 200);
|
||||
}
|
||||
|
||||
$filePath = WRITEPATH . "uploads/policy/{$folder}/" . $fileRecord[$fileColumn];
|
||||
$publicUrl = base_url("uploads/policy/{$folder}/" . $fileRecord[$fileColumn]);
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
if (!policy_exists_by_type($fileRecord[$fileColumn], $fileType)) {
|
||||
return $this->respond(['status' => 'failed','code' => 404,'data' => 'File missing on server'], 200);
|
||||
}
|
||||
|
||||
|
||||
// return $this->respond(['status' => 'success', 'code' => 200, 'data' => $publicUrl ], 200);
|
||||
|
||||
// Detect the file mime type
|
||||
$mime = mime_content_type($filePath);
|
||||
|
||||
// Stream file to browser / Flutter
|
||||
return $this->response
|
||||
->setHeader('Content-Type', $mime)
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $fileRecord[$fileColumn] . '"')
|
||||
->setBody(file_get_contents($filePath));
|
||||
return policy_download_by_type($fileRecord[$fileColumn], $fileType, true);
|
||||
|
||||
|
||||
} catch (\Exception $e) {
|
||||
@ -828,22 +751,22 @@ class PolicyController extends ResourceController
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
|
||||
|
||||
$record = $this->PolicyModel->find((int)$policyId);
|
||||
$uploadedPath = WRITEPATH . 'uploads/policy/policy_pdf/';
|
||||
$mdUploadedPath = WRITEPATH . 'uploads/policy/policy_md/';
|
||||
$pdfFilePath = $uploadedPath . $record['policy_pdf_file_name'];
|
||||
$mdFileName = preg_replace('/\.pdf$/i', '.md', $record['policy_pdf_file_name']);
|
||||
$mdFilePath = $mdUploadedPath . $mdFileName;
|
||||
$pdfFileName = $record['policy_pdf_file_name'] ?? '';
|
||||
$mdFileName = policy_md_file_name($pdfFileName);
|
||||
|
||||
if (!file_exists($pdfFilePath)) {
|
||||
log_message('info',"Error: File not found at {$pdfFilePath}");
|
||||
if ($pdfFileName === '' || !policy_exists_by_type($pdfFileName, 'policy_pdf')) {
|
||||
$missingPath = policy_local_pdf_path($pdfFileName);
|
||||
log_message('info',"Error: File not found at {$missingPath}");
|
||||
if($return == true)
|
||||
{
|
||||
return $this->respond(['status'=>"failed", 'message'=> "File not found at {$pdfFilePath}"], 200);
|
||||
return $this->respond(['status'=>"failed", 'message'=> "File not found at {$missingPath}"], 200);
|
||||
}else{
|
||||
return ['status'=>"failed", 'message'=> "File not found at {$pdfFilePath}"];
|
||||
return ['status'=>"failed", 'message'=> "File not found at {$missingPath}"];
|
||||
}
|
||||
}
|
||||
|
||||
$pdfFilePath = policy_local_pdf_path($pdfFileName);
|
||||
|
||||
$markdownResult = convert_policy_pdf_to_markdown($pdfFilePath);
|
||||
if ($markdownResult['status'] !== 'success') {
|
||||
log_message('info', 'Policy PDF to markdown conversion failed: ' . $markdownResult['message']);
|
||||
@ -854,27 +777,18 @@ class PolicyController extends ResourceController
|
||||
return ['status' => 'failed', 'message' => $markdownResult['message']];
|
||||
}
|
||||
|
||||
if (!file_exists($mdFilePath)) {
|
||||
log_message('info',"Error: Markdown file not found at {$mdFilePath}");
|
||||
if (!policy_md_exists($mdFileName)) {
|
||||
log_message('info',"Error: Markdown file not found for {$mdFileName}");
|
||||
if($return == true)
|
||||
{
|
||||
return $this->respond(['status'=>"failed", 'message'=> "Markdown file not found at {$mdFilePath}"], 200);
|
||||
return $this->respond(['status'=>"failed", 'message'=> "Markdown file not found for {$mdFileName}"], 200);
|
||||
}else{
|
||||
return ['status'=>"failed", 'message'=> "Markdown file not found at {$mdFilePath}"];
|
||||
return ['status'=>"failed", 'message'=> "Markdown file not found for {$mdFileName}"];
|
||||
}
|
||||
}
|
||||
|
||||
$filePath = $mdFilePath;
|
||||
|
||||
// Get the file's MIME type using the finfo extension
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_file($finfo, $filePath);
|
||||
finfo_close($finfo);
|
||||
|
||||
// Gemini inline supports text/plain reliably for markdown files
|
||||
if (!in_array($mimeType, ['text/plain', 'text/markdown', 'text/x-markdown'], true)) {
|
||||
$mimeType = 'text/plain';
|
||||
}
|
||||
$fileContent = $markdownResult['content'] ?? policy_read_md($mdFileName);
|
||||
$mimeType = 'text/markdown';
|
||||
|
||||
// Define supported inline MIME types
|
||||
$supportedInlineMimeTypes = ['application/pdf', 'text/csv', 'text/plain', 'text/markdown', 'text/x-markdown'];
|
||||
@ -891,16 +805,13 @@ class PolicyController extends ResourceController
|
||||
|
||||
// Conditionally handle the file upload based on MIME type
|
||||
if (in_array($mimeType, $supportedInlineMimeTypes)) {
|
||||
// Read saved markdown file from policy_md directory
|
||||
$fileContent = file_get_contents($filePath);
|
||||
|
||||
if ($fileContent === false || trim($fileContent) === '') {
|
||||
log_message('info', "Markdown file is empty at {$filePath}");
|
||||
if ($fileContent === '' || trim($fileContent) === '') {
|
||||
log_message('info', "Markdown file is empty for {$mdFileName}");
|
||||
if ($return == true) {
|
||||
return $this->respond(['status' => 'failed', 'message' => "Markdown file is empty at {$filePath}"], 200);
|
||||
return $this->respond(['status' => 'failed', 'message' => "Markdown file is empty for {$mdFileName}"], 200);
|
||||
}
|
||||
|
||||
return ['status' => 'failed', 'message' => "Markdown file is empty at {$filePath}"];
|
||||
return ['status' => 'failed', 'message' => "Markdown file is empty for {$mdFileName}"];
|
||||
}
|
||||
|
||||
$base64Content = base64_encode($fileContent);
|
||||
|
||||
@ -167,14 +167,16 @@ class PolicyRagController extends ResourceController
|
||||
'pdf_file' => $record['policy_pdf_file_name'] ?? null,
|
||||
]);
|
||||
|
||||
$uploadedPath = WRITEPATH . 'uploads/policy/policy_pdf/';
|
||||
$pdfFilePath = $uploadedPath . $record['policy_pdf_file_name'];
|
||||
$pdfFileName = $record['policy_pdf_file_name'] ?? '';
|
||||
|
||||
if (!file_exists($pdfFilePath)) {
|
||||
if ($pdfFileName === '' || !policy_exists_by_type($pdfFileName, 'policy_pdf')) {
|
||||
$pdfFilePath = $pdfFileName !== '' ? policy_local_pdf_path($pdfFileName) : '';
|
||||
$this->logRagStep('PDF_CHECK', 'failed', 'PDF file not found', ['path' => $pdfFilePath]);
|
||||
return $this->formatReadResponse('failed', "File not found at {$pdfFilePath}");
|
||||
}
|
||||
|
||||
$pdfFilePath = policy_local_pdf_path($pdfFileName);
|
||||
|
||||
$this->logRagStep('PDF_CHECK', 'success', 'PDF file found', ['path' => $pdfFilePath]);
|
||||
|
||||
$this->logRagStep('UPLOAD', 'started', 'Uploading PDF to RAG API', ['path' => $pdfFilePath]);
|
||||
|
||||
@ -290,20 +290,8 @@ class QuotationController extends ResourceController
|
||||
|
||||
|
||||
//create policy file
|
||||
$uploadPath = WRITEPATH . 'uploads/policy/';
|
||||
$policyPdf = $this->request->getFile('policy_pdf_file_name');
|
||||
$pdfFileName = null;
|
||||
// PDF Upload
|
||||
if ($policyPdf && $policyPdf->isValid()) {
|
||||
$pdfPath = $uploadPath . 'policy_pdf/';
|
||||
if (!is_dir($pdfPath)) {
|
||||
mkdir($pdfPath, 0777, true);
|
||||
}
|
||||
$pdfFileName = time() . '_' . $policyPdf->getRandomName();
|
||||
$policyPdf->move($pdfPath, $pdfFileName);
|
||||
|
||||
|
||||
//fetch enquiry_id & agent_id
|
||||
$pdfFileName = policy_upload_pdf($this->request->getFile('policy_pdf_file_name'));
|
||||
if ($pdfFileName !== null) {
|
||||
$quotData = $this->QuotationModel->select('partner_quotation.*,E.agent_id,E.name')
|
||||
->join('partner_enquiry E', 'E.id = partner_quotation.enquiry_id', 'left')
|
||||
->where('partner_quotation.id',$quotationId)
|
||||
@ -351,23 +339,18 @@ class QuotationController extends ResourceController
|
||||
/* ------------------------------------------------------
|
||||
* Update Policy (policy_id + file uploaded)
|
||||
* ------------------------------------------------------ */
|
||||
if (!empty($policyId) && $policyPdf && $policyPdf->isValid()) {
|
||||
if (!empty($policyId)) {
|
||||
$pdfFileName = policy_upload_pdf($policyPdf);
|
||||
|
||||
$uploadPath = WRITEPATH . 'uploads/policy/policy_pdf/';
|
||||
if (!is_dir($uploadPath)) {
|
||||
mkdir($uploadPath, 0777, true);
|
||||
if ($pdfFileName !== null) {
|
||||
$updateData = [
|
||||
'policy_pdf_file_name' => $pdfFileName,
|
||||
'updated_by' => $data['created_by'],
|
||||
];
|
||||
|
||||
$this->PolicyModel->update($policyId, $updateData);
|
||||
}
|
||||
|
||||
$pdfFileName = time() . '_' . $policyPdf->getRandomName();
|
||||
$policyPdf->move($uploadPath, $pdfFileName);
|
||||
|
||||
$updateData = [
|
||||
'policy_pdf_file_name' => $pdfFileName,
|
||||
'updated_by' => $data['created_by'],
|
||||
];
|
||||
|
||||
$this->PolicyModel->update($policyId, $updateData);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -429,54 +412,48 @@ class QuotationController extends ResourceController
|
||||
/* ------------------------------------------------------
|
||||
* If file uploaded → Create Policy
|
||||
* ------------------------------------------------------ */
|
||||
if (empty($policyId) && $policyPdf && $policyPdf->isValid()) {
|
||||
if (empty($policyId) && $policyPdf && $policyPdf->isValid()) {
|
||||
$pdfFileName = policy_upload_pdf($policyPdf);
|
||||
|
||||
$uploadDir = WRITEPATH . 'uploads/policy/policy_pdf/';
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0777, true);
|
||||
if ($pdfFileName !== null) {
|
||||
$quot = $this->QuotationModel
|
||||
->select('partner_quotation.*,E.agent_id,E.name')
|
||||
->join('partner_enquiry E', 'E.id = partner_quotation.enquiry_id')
|
||||
->where('partner_quotation.id', $newQuotationId ?? $quotationId)
|
||||
->first();
|
||||
|
||||
$policyInsert = [
|
||||
'enquiry_id' => $quot['enquiry_id'],
|
||||
'quotation_id' => $newQuotationId ?? $quotationId,
|
||||
'insured_name' => $quot['name'],
|
||||
'manager_id' => $quot['manager_id'],
|
||||
'agent_id' => $quot['agent_id'],
|
||||
'policy_pdf_file_name' => $pdfFileName,
|
||||
'created_by' => $data['created_by'] ?? 0,
|
||||
];
|
||||
|
||||
$newPolicyId = $this->PolicyModel->insert($policyInsert);
|
||||
|
||||
// Update enquiry status → Policy Created
|
||||
$this->EnquiryModel->update($quot['enquiry_id'], ['status' => 'Policy Created']);
|
||||
|
||||
Jobs::addJob(['job_name' => 'readFileAndCalculateCommission','payload' => ['policy_id' => $newPolicyId]]);
|
||||
log_message("info",'readFileAndCalculateCommission job pushed');
|
||||
|
||||
// Call helper to create BDS record after creating client,clientPolicy,vehicle records
|
||||
// $policyData = $this->PolicyModel->select('partner_policy.*,Q.insurer_id,Q.insurer_branch_id,E.name as client_name,E.mobile as client_mobile,E.email as client_email,E.reg_no,E.vehicle_type_id,A.id as agent_id,A.agent_code')
|
||||
// ->join('partner_quotation Q', 'Q.id = partner_policy.quotation_id AND Q.status = "Accepted"', 'left')
|
||||
// ->join('partner_enquiry E', 'E.id = partner_policy.enquiry_id', 'left')
|
||||
// ->join('partner_agent A', 'A.id = partner_policy.agent_id', 'left')
|
||||
// ->where('partner_policy.id',$newPolicyId)
|
||||
// ->first();
|
||||
// $bdsLogs = createBDS($policyData, $newPolicyId);
|
||||
// if (!empty($bdsLogs)) {
|
||||
// foreach ($bdsLogs as $msg) {
|
||||
// log_message('info', '[BDS Entry] ' . $msg);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
$pdfFileName = time() . '_' . $policyPdf->getRandomName();
|
||||
$policyPdf->move($uploadDir, $pdfFileName);
|
||||
|
||||
// fetch enquiry + agent
|
||||
$quot = $this->QuotationModel
|
||||
->select('partner_quotation.*,E.agent_id,E.name')
|
||||
->join('partner_enquiry E', 'E.id = partner_quotation.enquiry_id')
|
||||
->where('partner_quotation.id', $newQuotationId ?? $quotationId)
|
||||
->first();
|
||||
|
||||
$policyInsert = [
|
||||
'enquiry_id' => $quot['enquiry_id'],
|
||||
'quotation_id' => $newQuotationId ?? $quotationId,
|
||||
'insured_name' => $quot['name'],
|
||||
'manager_id' => $quot['manager_id'],
|
||||
'agent_id' => $quot['agent_id'],
|
||||
'policy_pdf_file_name' => $pdfFileName,
|
||||
'created_by' => $data['created_by'] ?? 0,
|
||||
];
|
||||
|
||||
$newPolicyId = $this->PolicyModel->insert($policyInsert);
|
||||
|
||||
// Update enquiry status → Policy Created
|
||||
$this->EnquiryModel->update($quot['enquiry_id'], ['status' => 'Policy Created']);
|
||||
|
||||
Jobs::addJob(['job_name' => 'readFileAndCalculateCommission','payload' => ['policy_id' => $newPolicyId]]);
|
||||
log_message("info",'readFileAndCalculateCommission job pushed');
|
||||
|
||||
// Call helper to create BDS record after creating client,clientPolicy,vehicle records
|
||||
// $policyData = $this->PolicyModel->select('partner_policy.*,Q.insurer_id,Q.insurer_branch_id,E.name as client_name,E.mobile as client_mobile,E.email as client_email,E.reg_no,E.vehicle_type_id,A.id as agent_id,A.agent_code')
|
||||
// ->join('partner_quotation Q', 'Q.id = partner_policy.quotation_id AND Q.status = "Accepted"', 'left')
|
||||
// ->join('partner_enquiry E', 'E.id = partner_policy.enquiry_id', 'left')
|
||||
// ->join('partner_agent A', 'A.id = partner_policy.agent_id', 'left')
|
||||
// ->where('partner_policy.id',$newPolicyId)
|
||||
// ->first();
|
||||
// $bdsLogs = createBDS($policyData, $newPolicyId);
|
||||
// if (!empty($bdsLogs)) {
|
||||
// foreach ($bdsLogs as $msg) {
|
||||
// log_message('info', '[BDS Entry] ' . $msg);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
|
||||
109
app/Controllers/StorageBrowserController.php
Normal file
109
app/Controllers/StorageBrowserController.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Services\Storage\FileStorageException;
|
||||
use App\Services\StorageBrowserService;
|
||||
use CodeIgniter\API\ResponseTrait;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class StorageBrowserController extends BaseController
|
||||
{
|
||||
use ResponseTrait;
|
||||
private StorageBrowserService $browser;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->browser = new StorageBrowserService();
|
||||
}
|
||||
|
||||
public function index(): string
|
||||
{
|
||||
return view('storage/file_browser', [
|
||||
'driver' => $this->browser->driverLabel(),
|
||||
'bucket' => $this->browser->bucketLabel(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function folders(): ResponseInterface
|
||||
{
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'driver' => $this->browser->driverLabel(),
|
||||
'bucket' => $this->browser->bucketLabel(),
|
||||
'folders' => $this->browser->folders(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function files(): ResponseInterface
|
||||
{
|
||||
try {
|
||||
$folder = (string) ($this->request->getGet('folder') ?? '');
|
||||
$search = $this->request->getGet('q');
|
||||
$token = $this->request->getGet('page_token');
|
||||
$limit = max(1, min(50, (int) ($this->request->getGet('limit') ?? 10)));
|
||||
|
||||
if ($folder === '') {
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'driver' => $this->browser->driverLabel(),
|
||||
'bucket' => $this->browser->bucketLabel(),
|
||||
'folder' => '',
|
||||
'files' => [],
|
||||
'has_more' => false,
|
||||
'next_token' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $this->browser->listFiles(
|
||||
$folder,
|
||||
$limit,
|
||||
is_string($token) && $token !== '' ? $token : null,
|
||||
is_string($search) ? $search : null
|
||||
);
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'driver' => $this->browser->driverLabel(),
|
||||
'bucket' => $this->browser->bucketLabel(),
|
||||
'folder' => $folder,
|
||||
'files' => $result['files'],
|
||||
'has_more' => $result['has_more'],
|
||||
'next_token' => $result['next_token'],
|
||||
]);
|
||||
} catch (FileStorageException $e) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function open()
|
||||
{
|
||||
try {
|
||||
$key = (string) ($this->request->getGet('key') ?? '');
|
||||
|
||||
return redirect()->to($this->browser->getOpenUrl($key));
|
||||
} catch (FileStorageException $e) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
public function download()
|
||||
{
|
||||
try {
|
||||
$key = (string) ($this->request->getGet('key') ?? '');
|
||||
|
||||
return $this->browser->downloadResponse($key);
|
||||
} catch (FileStorageException $e) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => $e->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -133,25 +133,22 @@ if (!function_exists('convert_policy_pdf_to_markdown')) {
|
||||
];
|
||||
}
|
||||
|
||||
if (!$forceRefresh && is_file($mdPath) && filemtime($mdPath) >= filemtime($pdfPath)) {
|
||||
$cached = file_get_contents($mdPath);
|
||||
if (!$forceRefresh && policy_md_exists($mdFileName)) {
|
||||
$cached = policy_read_md($mdFileName);
|
||||
|
||||
if ($cached !== false && policy_md_is_valid($cached)) {
|
||||
log_message('info', "Using cached policy markdown: {$mdPath}");
|
||||
if ($cached !== '' && policy_md_is_valid($cached)) {
|
||||
log_message('info', "Using cached policy markdown: {$mdFileName}");
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'message' => 'Cached markdown loaded from disk',
|
||||
'message' => 'Cached markdown loaded from storage',
|
||||
'content' => $cached,
|
||||
'md_path' => $mdPath,
|
||||
'md_file_name' => $mdFileName,
|
||||
];
|
||||
}
|
||||
|
||||
if ($cached !== false) {
|
||||
log_message('info', "Invalid cached markdown detected, reconverting: {$mdPath}");
|
||||
@unlink($mdPath);
|
||||
}
|
||||
log_message('info', "Invalid cached markdown detected, reconverting: {$mdFileName}");
|
||||
}
|
||||
|
||||
$extractedText = policy_pdf_extract_text($pdfPath);
|
||||
@ -172,6 +169,8 @@ if (!function_exists('convert_policy_pdf_to_markdown')) {
|
||||
];
|
||||
}
|
||||
|
||||
policy_put_md($mdFileName, $markdown);
|
||||
|
||||
$savedContent = file_get_contents($mdPath);
|
||||
|
||||
if ($savedContent === false || !policy_md_is_valid($savedContent)) {
|
||||
|
||||
224
app/Helpers/storage_helper.php
Normal file
224
app/Helpers/storage_helper.php
Normal file
@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
use App\Services\FileStorageService;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
if (!function_exists('storage')) {
|
||||
function storage(): FileStorageService
|
||||
{
|
||||
return service('fileStorage');
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('storage_upload_if_valid')) {
|
||||
function storage_upload_if_valid(?UploadedFile $file, string $module, string $subFolder = ''): ?string
|
||||
{
|
||||
if ($file === null || ! $file->isValid() || $file->hasMoved()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return storage()->uploadModuleFile($file, $module, $subFolder);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('storage_exists')) {
|
||||
function storage_exists(string $module, string $subFolder, string $fileName): bool
|
||||
{
|
||||
return $fileName !== '' && storage()->exists($module, $subFolder, $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('storage_download')) {
|
||||
function storage_download(string $module, string $subFolder, string $fileName): ResponseInterface
|
||||
{
|
||||
return storage()->download($module, $subFolder, $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('storage_inline')) {
|
||||
function storage_inline(string $module, string $subFolder, string $fileName): ResponseInterface
|
||||
{
|
||||
return storage()->download($module, $subFolder, $fileName)
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $fileName . '"');
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('storage_temporary_url')) {
|
||||
function storage_temporary_url(string $module, string $subFolder, string $fileName): string
|
||||
{
|
||||
return storage()->getTemporaryUrl($module, $subFolder, $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('storage_local_path')) {
|
||||
function storage_local_path(string $module, string $subFolder, string $fileName): string
|
||||
{
|
||||
return storage()->getLocalPathForProcessing($module, $subFolder, $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('storage_delete')) {
|
||||
function storage_delete(string $module, string $subFolder, string $fileName): bool
|
||||
{
|
||||
return storage()->delete($module, $subFolder, $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('storage_put')) {
|
||||
function storage_put(string $module, string $subFolder, string $fileName, string $contents, array $options = []): string
|
||||
{
|
||||
return storage()->put(storage()->resolveKey($module, $subFolder, $fileName), $contents, $options);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('storage_read')) {
|
||||
function storage_read(string $module, string $subFolder, string $fileName): string
|
||||
{
|
||||
return storage()->read($module, $subFolder, $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('policy_file_type_map')) {
|
||||
/**
|
||||
* @return array<string, array{column: string, folder: string}>
|
||||
*/
|
||||
function policy_file_type_map(): array
|
||||
{
|
||||
return [
|
||||
'policy_pdf' => ['column' => 'policy_pdf_file_name', 'folder' => 'policy_pdf'],
|
||||
'policy_payment_receipt' => ['column' => 'policy_payment_receipt_file_name', 'folder' => 'policy_payment_receipt'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('policy_upload_pdf')) {
|
||||
function policy_upload_pdf(?UploadedFile $file): ?string
|
||||
{
|
||||
return storage_upload_if_valid($file, 'policy', 'policy_pdf');
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('policy_upload_receipt')) {
|
||||
function policy_upload_receipt(?UploadedFile $file): ?string
|
||||
{
|
||||
return storage_upload_if_valid($file, 'policy', 'policy_payment_receipt');
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('policy_exists_by_type')) {
|
||||
function policy_exists_by_type(string $fileName, string $fileType): bool
|
||||
{
|
||||
$map = policy_file_type_map();
|
||||
|
||||
if (! isset($map[$fileType])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return storage_exists('policy', $map[$fileType]['folder'], $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('policy_download_by_type')) {
|
||||
function policy_download_by_type(string $fileName, string $fileType, bool $inline = false): ResponseInterface
|
||||
{
|
||||
$map = policy_file_type_map();
|
||||
|
||||
if (! isset($map[$fileType])) {
|
||||
throw new InvalidArgumentException('Invalid policy file type');
|
||||
}
|
||||
|
||||
$folder = $map[$fileType]['folder'];
|
||||
|
||||
return $inline
|
||||
? storage_inline('policy', $folder, $fileName)
|
||||
: storage_download('policy', $folder, $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('policy_local_pdf_path')) {
|
||||
function policy_local_pdf_path(string $fileName): string
|
||||
{
|
||||
return storage_local_path('policy', 'policy_pdf', $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('policy_md_file_name')) {
|
||||
function policy_md_file_name(string $pdfFileName): string
|
||||
{
|
||||
$mdFileName = preg_replace('/\.pdf$/i', '.md', $pdfFileName);
|
||||
|
||||
if ($mdFileName === $pdfFileName) {
|
||||
$mdFileName .= '.md';
|
||||
}
|
||||
|
||||
return $mdFileName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('policy_put_md')) {
|
||||
function policy_put_md(string $fileName, string $contents): string
|
||||
{
|
||||
return storage_put('policy', 'policy_md', $fileName, $contents, ['content_type' => 'text/markdown']);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('policy_read_md')) {
|
||||
function policy_read_md(string $fileName): string
|
||||
{
|
||||
return storage_read('policy', 'policy_md', $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('policy_md_exists')) {
|
||||
function policy_md_exists(string $fileName): bool
|
||||
{
|
||||
return storage_exists('policy', 'policy_md', $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('endorsement_upload_original')) {
|
||||
function endorsement_upload_original(?UploadedFile $file): ?string
|
||||
{
|
||||
return storage_upload_if_valid($file, 'endorsement', '');
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('endorsement_upload_completion')) {
|
||||
function endorsement_upload_completion(?UploadedFile $file): ?string
|
||||
{
|
||||
return storage_upload_if_valid($file, 'endorsement', 'endorsement_pdf');
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('endorsement_subfolder_for_type')) {
|
||||
function endorsement_subfolder_for_type(?string $type): string
|
||||
{
|
||||
return ($type === 'completion') ? 'endorsement_pdf' : '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('endorsement_exists_by_type')) {
|
||||
function endorsement_exists_by_type(string $fileName, ?string $type = 'original'): bool
|
||||
{
|
||||
return storage_exists('endorsement', endorsement_subfolder_for_type($type), $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('endorsement_download_by_type')) {
|
||||
function endorsement_download_by_type(string $fileName, ?string $type = 'original', bool $inline = false): ResponseInterface
|
||||
{
|
||||
$folder = endorsement_subfolder_for_type($type);
|
||||
|
||||
return $inline
|
||||
? storage_inline('endorsement', $folder, $fileName)
|
||||
: storage_download('endorsement', $folder, $fileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('endorsement_delete_completion')) {
|
||||
function endorsement_delete_completion(string $fileName): bool
|
||||
{
|
||||
return storage_delete('endorsement', 'endorsement_pdf', $fileName);
|
||||
}
|
||||
}
|
||||
176
app/Services/FileStorageService.php
Normal file
176
app/Services/FileStorageService.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\Storage\FileStorageInterface;
|
||||
use App\Services\Storage\LocalStorageDriver;
|
||||
use App\Services\Storage\S3StorageDriver;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Storage;
|
||||
|
||||
class FileStorageService
|
||||
{
|
||||
private FileStorageInterface $driver;
|
||||
|
||||
private Storage $config;
|
||||
|
||||
public function __construct(?Storage $config = null)
|
||||
{
|
||||
$this->config = $config ?? config('Storage');
|
||||
$this->driver = $this->createDriver($this->config->driver);
|
||||
}
|
||||
|
||||
public function driver(): FileStorageInterface
|
||||
{
|
||||
return $this->driver;
|
||||
}
|
||||
|
||||
public function isS3(): bool
|
||||
{
|
||||
return $this->config->driver === 's3';
|
||||
}
|
||||
|
||||
public function resolveKey(string $module, string $subFolder, string $fileName): string
|
||||
{
|
||||
$module = trim($module, '/');
|
||||
$subFolder = trim($subFolder, '/');
|
||||
$fileName = ltrim(str_replace('\\', '/', $fileName), '/');
|
||||
|
||||
if ($subFolder === '') {
|
||||
return "uploads/{$module}/{$fileName}";
|
||||
}
|
||||
|
||||
return "uploads/{$module}/{$subFolder}/{$fileName}";
|
||||
}
|
||||
|
||||
public function upload(UploadedFile|string $source, string $key, array $options = []): string
|
||||
{
|
||||
$storedKey = $this->driver->upload($source, $key, $options);
|
||||
|
||||
if ($this->config->retainLocal && $this->isS3()) {
|
||||
(new LocalStorageDriver($this->config))->upload($source, $key, $options);
|
||||
}
|
||||
|
||||
return $storedKey;
|
||||
}
|
||||
|
||||
public function uploadModuleFile(
|
||||
UploadedFile $file,
|
||||
string $module,
|
||||
string $subFolder = '',
|
||||
?string $fileName = null
|
||||
): string {
|
||||
$fileName = $fileName ?: (time() . '_' . $file->getRandomName());
|
||||
|
||||
$this->upload($file, $this->resolveKey($module, $subFolder, $fileName));
|
||||
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
public function put(string $key, string $contents, array $options = []): string
|
||||
{
|
||||
$storedKey = $this->driver->put($key, $contents, $options);
|
||||
|
||||
if ($this->config->retainLocal && $this->isS3()) {
|
||||
(new LocalStorageDriver($this->config))->put($key, $contents, $options);
|
||||
}
|
||||
|
||||
return $storedKey;
|
||||
}
|
||||
|
||||
public function exists(string $module, string $subFolder, string $fileName): bool
|
||||
{
|
||||
return $this->driver->exists($this->resolveKey($module, $subFolder, $fileName));
|
||||
}
|
||||
|
||||
public function existsKey(string $key): bool
|
||||
{
|
||||
return $this->driver->exists($key);
|
||||
}
|
||||
|
||||
public function delete(string $module, string $subFolder, string $fileName): bool
|
||||
{
|
||||
return $this->driver->delete($this->resolveKey($module, $subFolder, $fileName));
|
||||
}
|
||||
|
||||
public function deleteKey(string $key): bool
|
||||
{
|
||||
return $this->driver->delete($key);
|
||||
}
|
||||
|
||||
public function read(string $module, string $subFolder, string $fileName): string
|
||||
{
|
||||
return $this->driver->read($this->resolveKey($module, $subFolder, $fileName));
|
||||
}
|
||||
|
||||
public function readKey(string $key): string
|
||||
{
|
||||
return $this->driver->read($key);
|
||||
}
|
||||
|
||||
public function download(string $module, string $subFolder, string $fileName): ResponseInterface
|
||||
{
|
||||
return $this->driver->streamDownload(
|
||||
$this->resolveKey($module, $subFolder, $fileName),
|
||||
$fileName
|
||||
);
|
||||
}
|
||||
|
||||
public function downloadKey(string $key, ?string $downloadName = null): ResponseInterface
|
||||
{
|
||||
return $this->driver->streamDownload($key, $downloadName ?: basename($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a local filesystem path suitable for pdftotext, Gemini, RAG, etc.
|
||||
* For S3, this downloads to a temp file first.
|
||||
*/
|
||||
public function getLocalPathForProcessing(string $module, string $subFolder, string $fileName): string
|
||||
{
|
||||
return $this->getLocalPathForKey($this->resolveKey($module, $subFolder, $fileName));
|
||||
}
|
||||
|
||||
public function getLocalPathForKey(string $key): string
|
||||
{
|
||||
if ($this->config->driver === 'local') {
|
||||
return rtrim($this->config->localRoot, '/\\') . DIRECTORY_SEPARATOR . ltrim(str_replace('/', DIRECTORY_SEPARATOR, $key), '/\\');
|
||||
}
|
||||
|
||||
return $this->driver->downloadToTemp($key);
|
||||
}
|
||||
|
||||
public function getTemporaryUrl(string $module, string $subFolder, string $fileName, ?int $ttlSeconds = null): string
|
||||
{
|
||||
return $this->getTemporaryUrlForKey(
|
||||
$this->resolveKey($module, $subFolder, $fileName),
|
||||
$ttlSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public function getTemporaryUrlForKey(string $key, ?int $ttlSeconds = null): string
|
||||
{
|
||||
return $this->driver->temporaryUrl($key, $ttlSeconds ?? $this->config->presignedTtl);
|
||||
}
|
||||
|
||||
public function getUrl(string $module, string $subFolder, string $fileName): string
|
||||
{
|
||||
return $this->driver->url($this->resolveKey($module, $subFolder, $fileName));
|
||||
}
|
||||
|
||||
public function copy(string $fromModule, string $fromSubFolder, string $fromFileName, string $toModule, string $toSubFolder, string $toFileName): bool
|
||||
{
|
||||
return $this->driver->copy(
|
||||
$this->resolveKey($fromModule, $fromSubFolder, $fromFileName),
|
||||
$this->resolveKey($toModule, $toSubFolder, $toFileName)
|
||||
);
|
||||
}
|
||||
|
||||
private function createDriver(string $driver): FileStorageInterface
|
||||
{
|
||||
return match ($driver) {
|
||||
's3' => new S3StorageDriver($this->config),
|
||||
default => new LocalStorageDriver($this->config),
|
||||
};
|
||||
}
|
||||
}
|
||||
9
app/Services/Storage/FileStorageException.php
Normal file
9
app/Services/Storage/FileStorageException.php
Normal file
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Storage;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class FileStorageException extends RuntimeException
|
||||
{
|
||||
}
|
||||
40
app/Services/Storage/FileStorageInterface.php
Normal file
40
app/Services/Storage/FileStorageInterface.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Storage;
|
||||
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
interface FileStorageInterface
|
||||
{
|
||||
/**
|
||||
* Upload from a CI4 UploadedFile or a local filesystem path.
|
||||
*
|
||||
* @param array{content_type?: string} $options
|
||||
*/
|
||||
public function upload(UploadedFile|string $source, string $key, array $options = []): string;
|
||||
|
||||
/**
|
||||
* Upload raw string/binary content.
|
||||
*
|
||||
* @param array{content_type?: string} $options
|
||||
*/
|
||||
public function put(string $key, string $contents, array $options = []): string;
|
||||
|
||||
public function exists(string $key): bool;
|
||||
|
||||
public function delete(string $key): bool;
|
||||
|
||||
public function read(string $key): string;
|
||||
|
||||
/** Download object to a local temp file and return its path. */
|
||||
public function downloadToTemp(string $key): string;
|
||||
|
||||
public function streamDownload(string $key, ?string $downloadName = null): ResponseInterface;
|
||||
|
||||
public function temporaryUrl(string $key, int $ttlSeconds = 900): string;
|
||||
|
||||
public function url(string $key): string;
|
||||
|
||||
public function copy(string $fromKey, string $toKey): bool;
|
||||
}
|
||||
155
app/Services/Storage/LocalStorageDriver.php
Normal file
155
app/Services/Storage/LocalStorageDriver.php
Normal file
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Storage;
|
||||
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Storage;
|
||||
|
||||
class LocalStorageDriver implements FileStorageInterface
|
||||
{
|
||||
public function __construct(private readonly Storage $config)
|
||||
{
|
||||
}
|
||||
|
||||
private function fullPath(string $key): string
|
||||
{
|
||||
$key = ltrim(str_replace('\\', '/', $key), '/');
|
||||
|
||||
return $this->config->localRoot . str_replace('/', DIRECTORY_SEPARATOR, $key);
|
||||
}
|
||||
|
||||
private function ensureDirectory(string $fullPath): void
|
||||
{
|
||||
$directory = dirname($fullPath);
|
||||
|
||||
if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) {
|
||||
throw new FileStorageException("Unable to create directory: {$directory}");
|
||||
}
|
||||
}
|
||||
|
||||
public function upload(UploadedFile|string $source, string $key, array $options = []): string
|
||||
{
|
||||
$fullPath = $this->fullPath($key);
|
||||
$this->ensureDirectory($fullPath);
|
||||
|
||||
if ($source instanceof UploadedFile) {
|
||||
if (! $source->isValid()) {
|
||||
throw new FileStorageException($source->getErrorString() ?: 'Invalid uploaded file');
|
||||
}
|
||||
|
||||
if ($source->hasMoved()) {
|
||||
throw new FileStorageException('Uploaded file has already been moved');
|
||||
}
|
||||
|
||||
$source->move(dirname($fullPath), basename($fullPath));
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
if (! is_file($source)) {
|
||||
throw new FileStorageException("Source file not found: {$source}");
|
||||
}
|
||||
|
||||
if (! copy($source, $fullPath)) {
|
||||
throw new FileStorageException("Failed to copy file to {$fullPath}");
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
public function put(string $key, string $contents, array $options = []): string
|
||||
{
|
||||
$fullPath = $this->fullPath($key);
|
||||
$this->ensureDirectory($fullPath);
|
||||
|
||||
if (file_put_contents($fullPath, $contents) === false) {
|
||||
throw new FileStorageException("Failed to write file: {$fullPath}");
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
public function exists(string $key): bool
|
||||
{
|
||||
return is_file($this->fullPath($key));
|
||||
}
|
||||
|
||||
public function delete(string $key): bool
|
||||
{
|
||||
$fullPath = $this->fullPath($key);
|
||||
|
||||
if (! is_file($fullPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return unlink($fullPath);
|
||||
}
|
||||
|
||||
public function read(string $key): string
|
||||
{
|
||||
$fullPath = $this->fullPath($key);
|
||||
|
||||
if (! is_file($fullPath)) {
|
||||
throw new FileStorageException("File not found: {$key}");
|
||||
}
|
||||
|
||||
$contents = file_get_contents($fullPath);
|
||||
|
||||
if ($contents === false) {
|
||||
throw new FileStorageException("Unable to read file: {$key}");
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
public function downloadToTemp(string $key): string
|
||||
{
|
||||
$fullPath = $this->fullPath($key);
|
||||
|
||||
if (! is_file($fullPath)) {
|
||||
throw new FileStorageException("File not found: {$key}");
|
||||
}
|
||||
|
||||
return $fullPath;
|
||||
}
|
||||
|
||||
public function streamDownload(string $key, ?string $downloadName = null): ResponseInterface
|
||||
{
|
||||
$fullPath = $this->fullPath($key);
|
||||
|
||||
if (! is_file($fullPath)) {
|
||||
throw new FileStorageException("File not found: {$key}");
|
||||
}
|
||||
|
||||
return service('response')->download($fullPath, null)->setFileName($downloadName ?: basename($key));
|
||||
}
|
||||
|
||||
public function temporaryUrl(string $key, int $ttlSeconds = 900): string
|
||||
{
|
||||
if (! $this->exists($key)) {
|
||||
throw new FileStorageException("File not found: {$key}");
|
||||
}
|
||||
|
||||
return base_url(ltrim(str_replace('\\', '/', $key), '/'));
|
||||
}
|
||||
|
||||
public function url(string $key): string
|
||||
{
|
||||
return $this->temporaryUrl($key);
|
||||
}
|
||||
|
||||
public function copy(string $fromKey, string $toKey): bool
|
||||
{
|
||||
$fromPath = $this->fullPath($fromKey);
|
||||
$toPath = $this->fullPath($toKey);
|
||||
|
||||
if (! is_file($fromPath)) {
|
||||
throw new FileStorageException("Source file not found: {$fromKey}");
|
||||
}
|
||||
|
||||
$this->ensureDirectory($toPath);
|
||||
|
||||
return copy($fromPath, $toPath);
|
||||
}
|
||||
}
|
||||
232
app/Services/Storage/S3StorageDriver.php
Normal file
232
app/Services/Storage/S3StorageDriver.php
Normal file
@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Storage;
|
||||
|
||||
use Aws\Exception\AwsException;
|
||||
use Aws\S3\S3Client;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Storage;
|
||||
|
||||
class S3StorageDriver implements FileStorageInterface
|
||||
{
|
||||
private S3Client $client;
|
||||
|
||||
public function __construct(private readonly Storage $config)
|
||||
{
|
||||
$this->assertConfigured();
|
||||
$this->client = $this->createClient();
|
||||
}
|
||||
|
||||
private function assertConfigured(): void
|
||||
{
|
||||
if ($this->config->s3Bucket === '' || $this->config->s3AccessKey === '' || $this->config->s3SecretKey === '') {
|
||||
throw new FileStorageException('S3 storage is not configured. Check AWS_BUCKET, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY.');
|
||||
}
|
||||
}
|
||||
|
||||
private function createClient(): S3Client
|
||||
{
|
||||
return new S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => $this->config->s3Region,
|
||||
'credentials' => [
|
||||
'key' => $this->config->s3AccessKey,
|
||||
'secret' => $this->config->s3SecretKey,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function objectKey(string $key): string
|
||||
{
|
||||
$key = ltrim(str_replace('\\', '/', $key), '/');
|
||||
|
||||
return $this->config->s3Prefix !== ''
|
||||
? $this->config->s3Prefix . '/' . $key
|
||||
: $key;
|
||||
}
|
||||
|
||||
private function resolveContentType(UploadedFile|string $source, array $options): string
|
||||
{
|
||||
if (! empty($options['content_type'])) {
|
||||
return (string) $options['content_type'];
|
||||
}
|
||||
|
||||
if ($source instanceof UploadedFile) {
|
||||
return $source->getClientMimeType() ?: 'application/octet-stream';
|
||||
}
|
||||
|
||||
$mimeType = mime_content_type($source);
|
||||
|
||||
return $mimeType !== false ? $mimeType : 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function putObject(array $params): void
|
||||
{
|
||||
try {
|
||||
$this->client->putObject($params);
|
||||
} catch (AwsException $e) {
|
||||
throw new FileStorageException('S3 upload failed: ' . $e->getAwsErrorMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function upload(UploadedFile|string $source, string $key, array $options = []): string
|
||||
{
|
||||
$params = [
|
||||
'Bucket' => $this->config->s3Bucket,
|
||||
'Key' => $this->objectKey($key),
|
||||
'ContentType' => $this->resolveContentType($source, $options),
|
||||
];
|
||||
|
||||
if ($source instanceof UploadedFile) {
|
||||
if (! $source->isValid()) {
|
||||
throw new FileStorageException($source->getErrorString() ?: 'Invalid uploaded file');
|
||||
}
|
||||
|
||||
$params['SourceFile'] = $source->getTempName();
|
||||
} else {
|
||||
if (! is_file($source)) {
|
||||
throw new FileStorageException("Source file not found: {$source}");
|
||||
}
|
||||
|
||||
$params['SourceFile'] = $source;
|
||||
}
|
||||
|
||||
$this->putObject($params);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
public function put(string $key, string $contents, array $options = []): string
|
||||
{
|
||||
$this->putObject([
|
||||
'Bucket' => $this->config->s3Bucket,
|
||||
'Key' => $this->objectKey($key),
|
||||
'Body' => $contents,
|
||||
'ContentType' => $options['content_type'] ?? 'application/octet-stream',
|
||||
]);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
public function exists(string $key): bool
|
||||
{
|
||||
try {
|
||||
return $this->client->doesObjectExist(
|
||||
$this->config->s3Bucket,
|
||||
$this->objectKey($key)
|
||||
);
|
||||
} catch (AwsException $e) {
|
||||
throw new FileStorageException('S3 exists check failed: ' . $e->getAwsErrorMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(string $key): bool
|
||||
{
|
||||
try {
|
||||
$this->client->deleteObject([
|
||||
'Bucket' => $this->config->s3Bucket,
|
||||
'Key' => $this->objectKey($key),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (AwsException $e) {
|
||||
throw new FileStorageException('S3 delete failed: ' . $e->getAwsErrorMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function read(string $key): string
|
||||
{
|
||||
try {
|
||||
$result = $this->client->getObject([
|
||||
'Bucket' => $this->config->s3Bucket,
|
||||
'Key' => $this->objectKey($key),
|
||||
]);
|
||||
|
||||
return (string) $result['Body'];
|
||||
} catch (AwsException $e) {
|
||||
throw new FileStorageException('S3 read failed: ' . $e->getAwsErrorMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadToTemp(string $key): string
|
||||
{
|
||||
$tempPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('s3_', true) . '_' . basename($key);
|
||||
|
||||
try {
|
||||
$this->client->getObject([
|
||||
'Bucket' => $this->config->s3Bucket,
|
||||
'Key' => $this->objectKey($key),
|
||||
'SaveAs' => $tempPath,
|
||||
]);
|
||||
} catch (AwsException $e) {
|
||||
throw new FileStorageException('S3 download failed: ' . $e->getAwsErrorMessage(), 0, $e);
|
||||
}
|
||||
|
||||
return $tempPath;
|
||||
}
|
||||
|
||||
public function streamDownload(string $key, ?string $downloadName = null): ResponseInterface
|
||||
{
|
||||
try {
|
||||
$result = $this->client->getObject([
|
||||
'Bucket' => $this->config->s3Bucket,
|
||||
'Key' => $this->objectKey($key),
|
||||
]);
|
||||
} catch (AwsException $e) {
|
||||
throw new FileStorageException('S3 download failed: ' . $e->getAwsErrorMessage(), 0, $e);
|
||||
}
|
||||
|
||||
return service('response')
|
||||
->setHeader('Content-Type', $result['ContentType'] ?? 'application/octet-stream')
|
||||
->setHeader(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="' . ($downloadName ?: basename($key)) . '"'
|
||||
)
|
||||
->setBody((string) $result['Body']);
|
||||
}
|
||||
|
||||
public function temporaryUrl(string $key, int $ttlSeconds = 900): string
|
||||
{
|
||||
try {
|
||||
$command = $this->client->getCommand('GetObject', [
|
||||
'Bucket' => $this->config->s3Bucket,
|
||||
'Key' => $this->objectKey($key),
|
||||
]);
|
||||
|
||||
$request = $this->client->createPresignedRequest($command, "+{$ttlSeconds} seconds");
|
||||
|
||||
return (string) $request->getUri();
|
||||
} catch (AwsException $e) {
|
||||
throw new FileStorageException('S3 presigned URL failed: ' . $e->getAwsErrorMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function url(string $key): string
|
||||
{
|
||||
return sprintf(
|
||||
'https://%s.s3.%s.amazonaws.com/%s',
|
||||
$this->config->s3Bucket,
|
||||
$this->config->s3Region,
|
||||
rawurlencode($this->objectKey($key))
|
||||
);
|
||||
}
|
||||
|
||||
public function copy(string $fromKey, string $toKey): bool
|
||||
{
|
||||
try {
|
||||
$this->client->copyObject([
|
||||
'Bucket' => $this->config->s3Bucket,
|
||||
'CopySource' => $this->config->s3Bucket . '/' . $this->objectKey($fromKey),
|
||||
'Key' => $this->objectKey($toKey),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (AwsException $e) {
|
||||
throw new FileStorageException('S3 copy failed: ' . $e->getAwsErrorMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
340
app/Services/StorageBrowserService.php
Normal file
340
app/Services/StorageBrowserService.php
Normal file
@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\Storage\FileStorageException;
|
||||
use Aws\Exception\AwsException;
|
||||
use Aws\S3\S3Client;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Storage;
|
||||
|
||||
class StorageBrowserService
|
||||
{
|
||||
private Storage $config;
|
||||
|
||||
private FileStorageService $storage;
|
||||
|
||||
/** @var list<string> */
|
||||
private array $folders = [
|
||||
'uploads/policy/policy_pdf',
|
||||
'uploads/policy/policy_payment_receipt',
|
||||
'uploads/policy/policy_md',
|
||||
'uploads/endorsement',
|
||||
'uploads/endorsement/endorsement_pdf',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
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<array{value: string, label: string}>
|
||||
*/
|
||||
public function folders(): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($this->folders as $folder) {
|
||||
$items[] = [
|
||||
'value' => $folder,
|
||||
'label' => $folder,
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* files: list<array{key: string, file_name: string, size: int, size_human: string, last_modified: string}>,
|
||||
* 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<array{key: string, file_name: string, size: int, size_human: string, last_modified: string}>,
|
||||
* 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<array{key: string, file_name: string, size: int, size_human: string, last_modified: string}>,
|
||||
* 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';
|
||||
}
|
||||
}
|
||||
402
app/Views/storage/file_browser.php
Normal file
402
app/Views/storage/file_browser.php
Normal file
@ -0,0 +1,402 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>File browser</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--brand: #0d9488;
|
||||
--brand-dark: #0f766e;
|
||||
--brand-light: #ccfbf1;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f8fafc;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.page-wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem 3rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: #64748b;
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.bucket-badge {
|
||||
background: var(--brand-light);
|
||||
color: var(--brand-dark);
|
||||
border: 1px solid #99f6e4;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.filters {
|
||||
padding: 1.25rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.btn-brand {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
color: #fff;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.btn-brand:hover,
|
||||
.btn-brand:focus {
|
||||
background: var(--brand-dark);
|
||||
border-color: var(--brand-dark);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
border-bottom-color: #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
vertical-align: middle;
|
||||
border-bottom-color: #f1f5f9;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: var(--brand-light);
|
||||
color: var(--brand-dark);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.footer-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.9rem 1.25rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alert-inline {
|
||||
margin: 1rem 1.25rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-wrap">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">File browser</h1>
|
||||
<p class="page-subtitle">Browse upload objects by folder. Results load 10 at a time.</p>
|
||||
</div>
|
||||
<div class="bucket-badge" title="Storage driver: <?= esc($driver) ?>"><?= esc($bucket) ?></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div id="alertBox"></div>
|
||||
|
||||
<div class="filters">
|
||||
<div>
|
||||
<label for="folderSelect" class="form-label">Folder</label>
|
||||
<select id="folderSelect" class="form-select">
|
||||
<option value="">Search or select folder</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="searchInput" class="form-label">File name</label>
|
||||
<input id="searchInput" type="text" class="form-control" placeholder="Search file name...">
|
||||
</div>
|
||||
<div>
|
||||
<button id="searchBtn" class="btn btn-brand w-100">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File name</th>
|
||||
<th>Size</th>
|
||||
<th>Last modified</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="filesBody">
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📁</div>
|
||||
<div>Select a folder to load files</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="footer-bar">
|
||||
<div id="pageInfo">Page —</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="prevBtn" class="btn btn-outline-secondary btn-sm" disabled>Previous</button>
|
||||
<button id="nextBtn" class="btn btn-brand btn-sm" disabled>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const baseUrl = <?= json_encode(base_url()) ?>.replace(/\/$/, '');
|
||||
const apiBase = baseUrl + '/storage/browser';
|
||||
|
||||
const folderSelect = document.getElementById('folderSelect');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
const filesBody = document.getElementById('filesBody');
|
||||
const pageInfo = document.getElementById('pageInfo');
|
||||
const prevBtn = document.getElementById('prevBtn');
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
const alertBox = document.getElementById('alertBox');
|
||||
|
||||
let currentFolder = '';
|
||||
let currentSearch = '';
|
||||
let pageNumber = 1;
|
||||
let tokenStack = [''];
|
||||
let nextToken = null;
|
||||
|
||||
function showAlert(message, type = 'danger') {
|
||||
alertBox.innerHTML = `<div class="alert alert-${type} alert-inline mb-0">${message}</div>`;
|
||||
}
|
||||
|
||||
function clearAlert() {
|
||||
alertBox.innerHTML = '';
|
||||
}
|
||||
|
||||
function renderEmpty(message) {
|
||||
filesBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📁</div>
|
||||
<div>${message}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
filesBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-4 text-muted">Loading files...</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderFiles(files) {
|
||||
if (!files.length) {
|
||||
renderEmpty('No files found in this folder');
|
||||
return;
|
||||
}
|
||||
|
||||
filesBody.innerHTML = files.map((file) => {
|
||||
const openUrl = `${apiBase}/open?key=${encodeURIComponent(file.key)}`;
|
||||
const downloadUrl = `${apiBase}/download?key=${encodeURIComponent(file.key)}`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(file.file_name)}</td>
|
||||
<td>${escapeHtml(file.size_human)}</td>
|
||||
<td>${escapeHtml(file.last_modified || '—')}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline-primary me-1" href="${openUrl}" target="_blank" rel="noopener">Open</a>
|
||||
<a class="btn btn-sm btn-brand" href="${downloadUrl}">Download</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function updatePagination() {
|
||||
pageInfo.textContent = currentFolder ? `Page ${pageNumber}` : 'Page —';
|
||||
prevBtn.disabled = pageNumber <= 1;
|
||||
nextBtn.disabled = !nextToken;
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
const response = await fetch(`${apiBase}/folders`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'success') {
|
||||
showAlert(data.message || 'Unable to load folders');
|
||||
return;
|
||||
}
|
||||
|
||||
data.folders.forEach((folder) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = folder.value;
|
||||
option.textContent = folder.label;
|
||||
folderSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFiles(resetPagination = true) {
|
||||
clearAlert();
|
||||
|
||||
currentFolder = folderSelect.value;
|
||||
currentSearch = searchInput.value.trim();
|
||||
|
||||
if (!currentFolder) {
|
||||
renderEmpty('Select a folder to load files');
|
||||
pageNumber = 1;
|
||||
tokenStack = [''];
|
||||
nextToken = null;
|
||||
updatePagination();
|
||||
return;
|
||||
}
|
||||
|
||||
if (resetPagination) {
|
||||
pageNumber = 1;
|
||||
tokenStack = [''];
|
||||
}
|
||||
|
||||
renderLoading();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
folder: currentFolder,
|
||||
limit: '10',
|
||||
});
|
||||
|
||||
if (currentSearch) {
|
||||
params.set('q', currentSearch);
|
||||
}
|
||||
|
||||
const pageToken = tokenStack[tokenStack.length - 1];
|
||||
if (pageToken) {
|
||||
params.set('page_token', pageToken);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/files?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'success') {
|
||||
showAlert(data.message || 'Unable to load files');
|
||||
renderEmpty('Unable to load files');
|
||||
nextToken = null;
|
||||
updatePagination();
|
||||
return;
|
||||
}
|
||||
|
||||
renderFiles(data.files || []);
|
||||
nextToken = data.next_token || null;
|
||||
updatePagination();
|
||||
} catch (error) {
|
||||
showAlert('Network error while loading files');
|
||||
renderEmpty('Unable to load files');
|
||||
nextToken = null;
|
||||
updatePagination();
|
||||
}
|
||||
}
|
||||
|
||||
searchBtn.addEventListener('click', () => loadFiles(true));
|
||||
searchInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
loadFiles(true);
|
||||
}
|
||||
});
|
||||
|
||||
prevBtn.addEventListener('click', () => {
|
||||
if (pageNumber <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
tokenStack.pop();
|
||||
pageNumber -= 1;
|
||||
loadFiles(false);
|
||||
});
|
||||
|
||||
nextBtn.addEventListener('click', () => {
|
||||
if (!nextToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
tokenStack.push(nextToken);
|
||||
pageNumber += 1;
|
||||
loadFiles(false);
|
||||
});
|
||||
|
||||
loadFolders();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
build/.phpunit.cache/test-results
Normal file
1
build/.phpunit.cache/test-results
Normal file
@ -0,0 +1 @@
|
||||
{"version":2,"defects":{"Tests\\Unit\\Storage\\FileStorageServiceTest::testUploadModuleFileStoresPolicyPdf":8,"Tests\\Unit\\Storage\\FileStorageServiceTest::testDownloadReturnsAttachmentResponse":7,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testUploadFromUploadedFileStoresUnderLocalRoot":8,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testStreamDownloadReturnsResponseWithBody":7,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyUploadPdfStoresFile":8,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyUploadReceiptStoresFile":8,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyDownloadByTypeAttachment":7,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyDownloadByTypeInline":7,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyPdfUploadExistsReadAndDelete":1,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyReceiptUploadAndTemporaryUrl":1,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3EndorsementOriginalAndCompletionUpload":1,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testLocalDriverScenarioStillWorksWhenEnvIsLocal":1,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3DriverIsActiveFromEnv":1,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyMarkdownPutAndRead":1},"times":{"Tests\\Unit\\Storage\\FileStorageServiceTest::testResolveKeyWithSubFolder":0.003,"Tests\\Unit\\Storage\\FileStorageServiceTest::testResolveKeyWithoutSubFolder":0.001,"Tests\\Unit\\Storage\\FileStorageServiceTest::testUploadModuleFileStoresPolicyPdf":0.005,"Tests\\Unit\\Storage\\FileStorageServiceTest::testReadWriteAndDeleteByModule":0.001,"Tests\\Unit\\Storage\\FileStorageServiceTest::testGetLocalPathForProcessingPointsToWritableFile":0.001,"Tests\\Unit\\Storage\\FileStorageServiceTest::testCopyBetweenKeys":0.001,"Tests\\Unit\\Storage\\FileStorageServiceTest::testDownloadReturnsAttachmentResponse":0.003,"Tests\\Unit\\Storage\\FileStorageServiceTest::testGetTemporaryUrlForLocalUsesBaseUrl":0.002,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testUploadFromUploadedFileStoresUnderLocalRoot":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testUploadFromLocalPathCopiesFile":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testPutWriteRawContents":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testExistsReturnsFalseForMissingFile":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testDeleteRemovesFile":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testDownloadToTempReturnsExistingLocalPath":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testCopyDuplicatesFile":0.001,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testTemporaryUrlUsesBaseUrlForLocalDriver":0.002,"Tests\\Unit\\Storage\\LocalStorageDriverTest::testStreamDownloadReturnsResponseWithBody":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyFileTypeMapContainsExpectedTypes":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyMdFileNameConvertsPdfToMd":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyUploadPdfStoresFile":0.002,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyUploadReceiptStoresFile":0.002,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyUploadReturnsNullWhenNoFile":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyExistsByTypeReturnsFalseForInvalidType":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyDownloadByTypeAttachment":0.002,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyDownloadByTypeInline":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyDownloadByTypeThrowsForInvalidType":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyLocalPdfPathResolvesStoredPdf":0.001,"Tests\\Unit\\Storage\\PolicyStorageHelperTest::testPolicyMarkdownPutReadAndExists":0.001,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3DriverIsActiveFromEnv":0.003,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyPdfUploadExistsReadAndDelete":0.001,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyReceiptUploadAndTemporaryUrl":0.001,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3PolicyMarkdownPutAndRead":0.001,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testS3EndorsementOriginalAndCompletionUpload":0.001,"Tests\\Unit\\Storage\\S3StorageIntegrationTest::testLocalDriverScenarioStillWorksWhenEnvIsLocal":0.006}}
|
||||
23
build/logs/logfile.xml
Normal file
23
build/logs/logfile.xml
Normal file
@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites>
|
||||
<testsuite name="CLI Arguments" tests="6" assertions="3" errors="0" failures="0" skipped="5" time="0.027320">
|
||||
<testsuite name="Tests\Unit\Storage\S3StorageIntegrationTest" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" tests="6" assertions="3" errors="0" failures="0" skipped="5" time="0.027320">
|
||||
<testcase name="testS3DriverIsActiveFromEnv" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="39" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="0" time="0.010998">
|
||||
<skipped/>
|
||||
</testcase>
|
||||
<testcase name="testS3PolicyPdfUploadExistsReadAndDelete" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="46" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="0" time="0.002448">
|
||||
<skipped/>
|
||||
</testcase>
|
||||
<testcase name="testS3PolicyReceiptUploadAndTemporaryUrl" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="69" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="0" time="0.002169">
|
||||
<skipped/>
|
||||
</testcase>
|
||||
<testcase name="testS3PolicyMarkdownPutAndRead" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="87" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="0" time="0.002258">
|
||||
<skipped/>
|
||||
</testcase>
|
||||
<testcase name="testS3EndorsementOriginalAndCompletionUpload" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="101" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="0" time="0.002006">
|
||||
<skipped/>
|
||||
</testcase>
|
||||
<testcase name="testLocalDriverScenarioStillWorksWhenEnvIsLocal" file="/var/www/html/nhance_partner_be/tests/unit/Storage/S3StorageIntegrationTest.php" line="121" class="Tests\Unit\Storage\S3StorageIntegrationTest" classname="Tests.Unit.Storage.S3StorageIntegrationTest" assertions="3" time="0.007442"/>
|
||||
</testsuite>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
64
build/logs/testdox.html
Normal file
64
build/logs/testdox.html
Normal file
@ -0,0 +1,64 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Test Documentation</title>
|
||||
<style>
|
||||
body {
|
||||
text-rendering: optimizeLegibility;
|
||||
font-family: Source SansSerif Pro, Arial, sans-serif;
|
||||
font-variant-ligatures: common-ligatures;
|
||||
font-kerning: normal;
|
||||
margin-left: 2rem;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
body > ul > li {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: larger;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0 0 2rem;
|
||||
padding: 0 0 0 1rem;
|
||||
text-indent: -1rem;
|
||||
}
|
||||
|
||||
.success:before {
|
||||
color: #4e9a06;
|
||||
content: '✓';
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.defect {
|
||||
color: #a40000;
|
||||
}
|
||||
|
||||
.defect:before {
|
||||
color: #a40000;
|
||||
content: '✗';
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>S3Storage Integration (Tests\Unit\Storage\S3StorageIntegration)</h2>
|
||||
<ul>
|
||||
<li class="defect">S 3 driver is active from env</li>
|
||||
<li class="defect">S 3 policy pdf upload exists read and delete</li>
|
||||
<li class="defect">S 3 policy receipt upload and temporary url</li>
|
||||
<li class="defect">S 3 policy markdown put and read</li>
|
||||
<li class="defect">S 3 endorsement original and completion upload</li>
|
||||
<li class="success">Local driver scenario still works when env is local</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
8
build/logs/testdox.txt
Normal file
8
build/logs/testdox.txt
Normal file
@ -0,0 +1,8 @@
|
||||
S3Storage Integration (Tests\Unit\Storage\S3StorageIntegration)
|
||||
[ ] S 3 driver is active from env
|
||||
[ ] S 3 policy pdf upload exists read and delete
|
||||
[ ] S 3 policy receipt upload and temporary url
|
||||
[ ] S 3 policy markdown put and read
|
||||
[ ] S 3 endorsement original and completion upload
|
||||
[x] Local driver scenario still works when env is local
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"php": "^8.1",
|
||||
"ext-intl": "*",
|
||||
"ext-mbstring": "*",
|
||||
"aws/aws-sdk-php": "^3.388",
|
||||
"firebase/php-jwt": "^6.11",
|
||||
"google/apiclient": "^2.15.0",
|
||||
"google/apiclient-services": "^0.396.0",
|
||||
|
||||
221
composer.lock
generated
221
composer.lock
generated
@ -4,8 +4,159 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5deed3009af506892988b5576e8718bb",
|
||||
"content-hash": "2d78e1b5528d683dee2775cdbade09a1",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
"version": "v1.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||
"yoast/phpunit-polyfills": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "AWS SDK Common Runtime Team",
|
||||
"email": "aws-sdk-common-runtime@amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS Common Runtime for PHP",
|
||||
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"crt",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||
},
|
||||
"time": "2024-10-18T22:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.388.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "9ad7cc3619513bb7379e2f1dc8f6763cc54dcb28"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/9ad7cc3619513bb7379e2f1dc8f6763cc54dcb28",
|
||||
"reference": "9ad7cc3619513bb7379e2f1dc8f6763cc54dcb28",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-crt-php": "^1.2.3",
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-simplexml": "*",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"guzzlehttp/promises": "^2.0",
|
||||
"guzzlehttp/psr7": "^2.4.5",
|
||||
"mtdowling/jmespath.php": "^2.9.1",
|
||||
"php": ">=8.1",
|
||||
"psr/http-message": "^1.0 || ^2.0",
|
||||
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"andrewsville/php-token-reflection": "^1.4",
|
||||
"aws/aws-php-sns-message-validator": "~1.0",
|
||||
"behat/behat": "~3.0",
|
||||
"composer/composer": "^2.7.8",
|
||||
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
|
||||
"doctrine/cache": "~1.4",
|
||||
"ext-dom": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-sockets": "*",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"psr/cache": "^2.0 || ^3.0",
|
||||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||
"ext-curl": "To send requests using cURL",
|
||||
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||
"ext-pcntl": "To use client-side monitoring",
|
||||
"ext-sockets": "To use client-side monitoring"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Aws\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"src/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Amazon Web Services",
|
||||
"homepage": "https://aws.amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||
"homepage": "https://aws.amazon.com/sdk-for-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"cloud",
|
||||
"dynamodb",
|
||||
"ec2",
|
||||
"glacier",
|
||||
"s3",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.388.7"
|
||||
},
|
||||
"time": "2026-07-15T18:08:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.3.2",
|
||||
@ -1457,6 +1608,72 @@
|
||||
},
|
||||
"time": "2023-05-03T06:19:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mtdowling/jmespath.php",
|
||||
"version": "2.9.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
|
||||
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/xdebug-handler": "^3.0.3",
|
||||
"phpunit/phpunit": "^8.5.52"
|
||||
},
|
||||
"bin": [
|
||||
"bin/jp.php"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.9-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/JmesPath.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"JmesPath\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
}
|
||||
],
|
||||
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||
"keywords": [
|
||||
"json",
|
||||
"jsonpath"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.2"
|
||||
},
|
||||
"time": "2026-07-06T18:56:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "myclabs/deep-copy",
|
||||
"version": "1.13.4",
|
||||
@ -9687,5 +9904,5 @@
|
||||
"ext-mbstring": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.2.0"
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
128
tests/_support/Storage/StorageTestCase.php
Normal file
128
tests/_support/Storage/StorageTestCase.php
Normal file
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support\Storage;
|
||||
|
||||
use App\Services\FileStorageService;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use Config\Services;
|
||||
use Config\Storage;
|
||||
|
||||
abstract class StorageTestCase extends CIUnitTestCase
|
||||
{
|
||||
protected string $tempRoot;
|
||||
|
||||
protected Storage $storageConfig;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->resetServices();
|
||||
|
||||
$this->tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'nhance_storage_test_' . uniqid('', true);
|
||||
mkdir($this->tempRoot, 0777, true);
|
||||
|
||||
$this->storageConfig = new Storage();
|
||||
$this->storageConfig->driver = 'local';
|
||||
$this->storageConfig->retainLocal = false;
|
||||
$this->storageConfig->localRoot = $this->tempRoot . DIRECTORY_SEPARATOR;
|
||||
$this->storageConfig->presignedTtl = 900;
|
||||
|
||||
Services::injectMock('fileStorage', new FileStorageService($this->storageConfig));
|
||||
|
||||
helper('storage_helper');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->deleteDirectory($this->tempRoot);
|
||||
$this->resetServices();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
protected function useLocalDriver(): void
|
||||
{
|
||||
$this->storageConfig->driver = 'local';
|
||||
Services::injectMock('fileStorage', new FileStorageService($this->storageConfig));
|
||||
}
|
||||
|
||||
protected function useS3DriverFromEnv(): void
|
||||
{
|
||||
$this->storageConfig->driver = 's3';
|
||||
$this->storageConfig->s3Bucket = getenv('AWS_BUCKET') ?: '';
|
||||
$this->storageConfig->s3Region = getenv('AWS_DEFAULT_REGION') ?: 'ap-south-1';
|
||||
$this->storageConfig->s3AccessKey = getenv('AWS_ACCESS_KEY_ID') ?: '';
|
||||
$this->storageConfig->s3SecretKey = getenv('AWS_SECRET_ACCESS_KEY') ?: '';
|
||||
$this->storageConfig->s3Prefix = trim(getenv('AWS_S3_PREFIX') ?: '', '/');
|
||||
|
||||
Services::injectMock('fileStorage', new FileStorageService($this->storageConfig));
|
||||
}
|
||||
|
||||
protected function s3IntegrationEnabled(): bool
|
||||
{
|
||||
return getenv('FILE_STORAGE_DRIVER') === 's3'
|
||||
&& getenv('AWS_BUCKET') !== false
|
||||
&& getenv('AWS_BUCKET') !== ''
|
||||
&& getenv('AWS_ACCESS_KEY_ID') !== false
|
||||
&& getenv('AWS_ACCESS_KEY_ID') !== ''
|
||||
&& getenv('AWS_SECRET_ACCESS_KEY') !== false
|
||||
&& getenv('AWS_SECRET_ACCESS_KEY') !== '';
|
||||
}
|
||||
|
||||
protected function skipUnlessS3Integration(): void
|
||||
{
|
||||
if (! $this->s3IntegrationEnabled()) {
|
||||
$this->markTestSkipped('S3 integration env is not configured (FILE_STORAGE_DRIVER=s3 and AWS_* vars).');
|
||||
}
|
||||
|
||||
$this->useS3DriverFromEnv();
|
||||
}
|
||||
|
||||
protected function createUploadedFile(
|
||||
string $contents,
|
||||
string $originalName,
|
||||
string $mime = 'application/octet-stream'
|
||||
): UploadedFile {
|
||||
$temp = tempnam(sys_get_temp_dir(), 'ci_upload_');
|
||||
file_put_contents($temp, $contents);
|
||||
|
||||
return new TestUploadedFile($temp, $originalName, $mime, strlen($contents), UPLOAD_ERR_OK, false);
|
||||
}
|
||||
|
||||
protected function localFilePath(string $key): string
|
||||
{
|
||||
return $this->tempRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, ltrim($key, '/'));
|
||||
}
|
||||
|
||||
protected function deleteDirectory(string $dir): void
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (scandir($dir) as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||
|
||||
if (is_dir($path)) {
|
||||
$this->deleteDirectory($path);
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
protected function assertDownloadHeaders(\CodeIgniter\HTTP\DownloadResponse $response, string $disposition): void
|
||||
{
|
||||
$response->buildHeaders();
|
||||
|
||||
$this->assertStringContainsString($disposition, $response->getHeaderLine('Content-Disposition'));
|
||||
}
|
||||
}
|
||||
49
tests/_support/Storage/TestUploadedFile.php
Normal file
49
tests/_support/Storage/TestUploadedFile.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support\Storage;
|
||||
|
||||
use CodeIgniter\HTTP\Exceptions\HTTPException;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
|
||||
/**
|
||||
* UploadedFile test double that works outside HTTP upload context.
|
||||
*/
|
||||
class TestUploadedFile extends UploadedFile
|
||||
{
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->getError() === UPLOAD_ERR_OK && is_file($this->getTempName());
|
||||
}
|
||||
|
||||
public function move(string $targetPath, ?string $name = null, bool $overwrite = false)
|
||||
{
|
||||
$targetPath = rtrim($targetPath, '/') . '/';
|
||||
|
||||
if (! is_dir($targetPath) && ! mkdir($targetPath, 0777, true) && ! is_dir($targetPath)) {
|
||||
throw HTTPException::forMoveFailed(basename($this->getTempName()), $targetPath, 'Unable to create target directory');
|
||||
}
|
||||
|
||||
if ($this->hasMoved()) {
|
||||
throw HTTPException::forAlreadyMoved();
|
||||
}
|
||||
|
||||
if (! $this->isValid()) {
|
||||
throw HTTPException::forInvalidFile();
|
||||
}
|
||||
|
||||
$name = $name ?? $this->getName();
|
||||
$destination = $overwrite ? $targetPath . $name : $targetPath . $name;
|
||||
|
||||
if (! @rename($this->getTempName(), $destination)) {
|
||||
if (! @copy($this->getTempName(), $destination)) {
|
||||
throw HTTPException::forMoveFailed(basename($this->getTempName()), $targetPath, 'rename/copy failed');
|
||||
}
|
||||
|
||||
@unlink($this->getTempName());
|
||||
}
|
||||
|
||||
$this->hasMoved = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
136
tests/unit/Storage/FileStorageServiceTest.php
Normal file
136
tests/unit/Storage/FileStorageServiceTest.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Storage;
|
||||
|
||||
use CodeIgniter\HTTP\DownloadResponse;
|
||||
use Tests\Support\Storage\StorageTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class FileStorageServiceTest extends StorageTestCase
|
||||
{
|
||||
public function testResolveKeyWithSubFolder(): void
|
||||
{
|
||||
$key = storage()->resolveKey('policy', 'policy_pdf', 'abc.pdf');
|
||||
|
||||
$this->assertSame('uploads/policy/policy_pdf/abc.pdf', $key);
|
||||
}
|
||||
|
||||
public function testResolveKeyWithoutSubFolder(): void
|
||||
{
|
||||
$key = storage()->resolveKey('endorsement', '', 'original.pdf');
|
||||
|
||||
$this->assertSame('uploads/endorsement/original.pdf', $key);
|
||||
}
|
||||
|
||||
public function testUploadModuleFileStoresPolicyPdf(): void
|
||||
{
|
||||
$file = $this->createUploadedFile('%PDF', 'policy.pdf', 'application/pdf');
|
||||
|
||||
$storedName = storage()->uploadModuleFile($file, 'policy', 'policy_pdf');
|
||||
|
||||
$this->assertNotSame('', $storedName);
|
||||
$this->assertTrue(storage()->exists('policy', 'policy_pdf', $storedName));
|
||||
}
|
||||
|
||||
public function testReadWriteAndDeleteByModule(): void
|
||||
{
|
||||
storage()->put(storage()->resolveKey('policy', 'policy_md', 'doc.md'), '# md');
|
||||
|
||||
$this->assertTrue(storage()->exists('policy', 'policy_md', 'doc.md'));
|
||||
$this->assertSame('# md', storage()->read('policy', 'policy_md', 'doc.md'));
|
||||
|
||||
$this->assertTrue(storage()->delete('policy', 'policy_md', 'doc.md'));
|
||||
$this->assertFalse(storage()->exists('policy', 'policy_md', 'doc.md'));
|
||||
}
|
||||
|
||||
public function testGetLocalPathForProcessingPointsToWritableFile(): void
|
||||
{
|
||||
$fileName = 'local-path.pdf';
|
||||
storage()->put(storage()->resolveKey('policy', 'policy_pdf', $fileName), 'local');
|
||||
|
||||
$path = storage()->getLocalPathForProcessing('policy', 'policy_pdf', $fileName);
|
||||
|
||||
$this->assertFileExists($path);
|
||||
$this->assertSame('local', file_get_contents($path));
|
||||
}
|
||||
|
||||
public function testCopyBetweenKeys(): void
|
||||
{
|
||||
storage()->put(storage()->resolveKey('policy', 'policy_pdf', 'from.pdf'), 'from');
|
||||
storage()->copy('policy', 'policy_pdf', 'from.pdf', 'policy', 'policy_pdf', 'to.pdf');
|
||||
|
||||
$this->assertSame('from', storage()->read('policy', 'policy_pdf', 'to.pdf'));
|
||||
}
|
||||
|
||||
public function testDownloadReturnsAttachmentResponse(): void
|
||||
{
|
||||
storage()->put(storage()->resolveKey('policy', 'policy_pdf', 'dl.pdf'), 'download');
|
||||
|
||||
$response = storage()->download('policy', 'policy_pdf', 'dl.pdf');
|
||||
|
||||
$this->assertInstanceOf(DownloadResponse::class, $response);
|
||||
$this->assertDownloadHeaders($response, 'attachment');
|
||||
$this->assertGreaterThan(0, $response->getContentLength());
|
||||
}
|
||||
|
||||
public function testGetTemporaryUrlForLocalUsesBaseUrl(): void
|
||||
{
|
||||
storage()->put(storage()->resolveKey('policy', 'policy_pdf', 'url.pdf'), 'url');
|
||||
|
||||
$url = storage()->getTemporaryUrl('policy', 'policy_pdf', 'url.pdf');
|
||||
|
||||
$this->assertStringContainsString('uploads/policy/policy_pdf/url.pdf', $url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class StorageHelperTest extends StorageTestCase
|
||||
{
|
||||
public function testStorageUploadIfValidReturnsNullForInvalidFile(): void
|
||||
{
|
||||
$this->assertNull(storage_upload_if_valid(null, 'policy', 'policy_pdf'));
|
||||
}
|
||||
|
||||
public function testStorageExistsReturnsFalseForEmptyFileName(): void
|
||||
{
|
||||
$this->assertFalse(storage_exists('policy', 'policy_pdf', ''));
|
||||
}
|
||||
|
||||
public function testStoragePutAndReadRoundTrip(): void
|
||||
{
|
||||
storage_put('policy', 'policy_md', 'helper.md', '# helper');
|
||||
|
||||
$this->assertTrue(storage_exists('policy', 'policy_md', 'helper.md'));
|
||||
$this->assertSame('# helper', storage_read('policy', 'policy_md', 'helper.md'));
|
||||
}
|
||||
|
||||
public function testStorageDeleteRemovesFile(): void
|
||||
{
|
||||
storage_put('endorsement', 'endorsement_pdf', 'delete-me.pdf', 'pdf');
|
||||
$this->assertTrue(storage_delete('endorsement', 'endorsement_pdf', 'delete-me.pdf'));
|
||||
$this->assertFalse(storage_exists('endorsement', 'endorsement_pdf', 'delete-me.pdf'));
|
||||
}
|
||||
|
||||
public function testStorageInlineSetsInlineDisposition(): void
|
||||
{
|
||||
storage_put('policy', 'policy_pdf', 'inline.pdf', 'inline');
|
||||
|
||||
$response = storage_inline('policy', 'policy_pdf', 'inline.pdf');
|
||||
|
||||
$this->assertStringContainsString('inline', $response->getHeaderLine('Content-Disposition'));
|
||||
}
|
||||
|
||||
public function testStorageLocalPathMatchesStoredFile(): void
|
||||
{
|
||||
storage_put('policy', 'policy_pdf', 'process.pdf', 'process');
|
||||
|
||||
$path = storage_local_path('policy', 'policy_pdf', 'process.pdf');
|
||||
|
||||
$this->assertFileExists($path);
|
||||
$this->assertSame('process', file_get_contents($path));
|
||||
}
|
||||
}
|
||||
189
tests/unit/Storage/LocalStorageDriverTest.php
Normal file
189
tests/unit/Storage/LocalStorageDriverTest.php
Normal file
@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Storage;
|
||||
|
||||
use App\Services\Storage\FileStorageException;
|
||||
use App\Services\Storage\LocalStorageDriver;
|
||||
use App\Services\Storage\S3StorageDriver;
|
||||
use CodeIgniter\HTTP\DownloadResponse;
|
||||
use Config\Storage;
|
||||
use Tests\Support\Storage\StorageTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class StorageConfigTest extends StorageTestCase
|
||||
{
|
||||
public function testDefaultsToLocalDriverWhenEnvMissing(): void
|
||||
{
|
||||
$config = new Storage();
|
||||
|
||||
$this->assertSame('local', $config->driver);
|
||||
}
|
||||
|
||||
public function testInvalidDriverFallsBackToLocal(): void
|
||||
{
|
||||
$previous = getenv('FILE_STORAGE_DRIVER');
|
||||
putenv('FILE_STORAGE_DRIVER=invalid');
|
||||
|
||||
try {
|
||||
$config = new Storage();
|
||||
$this->assertSame('local', $config->driver);
|
||||
} finally {
|
||||
if ($previous === false) {
|
||||
putenv('FILE_STORAGE_DRIVER');
|
||||
} else {
|
||||
putenv('FILE_STORAGE_DRIVER=' . $previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testRetainLocalParsesBooleanEnv(): void
|
||||
{
|
||||
$previous = getenv('RETAIN_LOCAL');
|
||||
putenv('RETAIN_LOCAL=true');
|
||||
|
||||
try {
|
||||
$config = new Storage();
|
||||
$this->assertTrue($config->retainLocal);
|
||||
} finally {
|
||||
if ($previous === false) {
|
||||
putenv('RETAIN_LOCAL');
|
||||
} else {
|
||||
putenv('RETAIN_LOCAL=' . $previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testS3DriverRequiresCredentials(): void
|
||||
{
|
||||
$config = new Storage();
|
||||
$config->driver = 's3';
|
||||
$config->s3Bucket = '';
|
||||
$config->s3AccessKey = '';
|
||||
$config->s3SecretKey = '';
|
||||
|
||||
$this->expectException(FileStorageException::class);
|
||||
$this->expectExceptionMessage('S3 storage is not configured');
|
||||
|
||||
new S3StorageDriver($config);
|
||||
}
|
||||
|
||||
public function testFileStorageServiceReportsDriverMode(): void
|
||||
{
|
||||
$this->assertFalse(storage()->isS3());
|
||||
|
||||
$this->storageConfig->driver = 's3';
|
||||
$this->storageConfig->s3Bucket = 'test-bucket';
|
||||
$this->storageConfig->s3AccessKey = 'key';
|
||||
$this->storageConfig->s3SecretKey = 'secret';
|
||||
|
||||
\Config\Services::injectMock('fileStorage', new \App\Services\FileStorageService($this->storageConfig));
|
||||
|
||||
$this->assertTrue(storage()->isS3());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class LocalStorageDriverTest extends StorageTestCase
|
||||
{
|
||||
private LocalStorageDriver $driver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->driver = new LocalStorageDriver($this->storageConfig);
|
||||
}
|
||||
|
||||
public function testUploadFromUploadedFileStoresUnderLocalRoot(): void
|
||||
{
|
||||
$file = $this->createUploadedFile('%PDF-1.4 policy', 'policy.pdf', 'application/pdf');
|
||||
$key = 'uploads/policy/policy_pdf/test_policy.pdf';
|
||||
|
||||
$this->driver->upload($file, $key);
|
||||
|
||||
$this->assertFileExists($this->localFilePath($key));
|
||||
$this->assertSame('%PDF-1.4 policy', file_get_contents($this->localFilePath($key)));
|
||||
}
|
||||
|
||||
public function testUploadFromLocalPathCopiesFile(): void
|
||||
{
|
||||
$source = tempnam(sys_get_temp_dir(), 'src_');
|
||||
file_put_contents($source, 'receipt bytes');
|
||||
$key = 'uploads/policy/policy_payment_receipt/receipt.pdf';
|
||||
|
||||
$this->driver->upload($source, $key);
|
||||
|
||||
$this->assertFileExists($this->localFilePath($key));
|
||||
$this->assertSame('receipt bytes', file_get_contents($this->localFilePath($key)));
|
||||
}
|
||||
|
||||
public function testPutWriteRawContents(): void
|
||||
{
|
||||
$key = 'uploads/policy/policy_md/sample.md';
|
||||
|
||||
$this->driver->put($key, '# Markdown content');
|
||||
|
||||
$this->assertTrue($this->driver->exists($key));
|
||||
$this->assertSame('# Markdown content', $this->driver->read($key));
|
||||
}
|
||||
|
||||
public function testExistsReturnsFalseForMissingFile(): void
|
||||
{
|
||||
$this->assertFalse($this->driver->exists('uploads/policy/policy_pdf/missing.pdf'));
|
||||
}
|
||||
|
||||
public function testDeleteRemovesFile(): void
|
||||
{
|
||||
$key = 'uploads/endorsement/original.pdf';
|
||||
$this->driver->put($key, 'endorsement');
|
||||
|
||||
$this->assertTrue($this->driver->delete($key));
|
||||
$this->assertFalse($this->driver->exists($key));
|
||||
}
|
||||
|
||||
public function testDownloadToTempReturnsExistingLocalPath(): void
|
||||
{
|
||||
$key = 'uploads/policy/policy_pdf/read.pdf';
|
||||
$this->driver->put($key, 'pdf-data');
|
||||
|
||||
$path = $this->driver->downloadToTemp($key);
|
||||
|
||||
$this->assertSame($this->localFilePath($key), $path);
|
||||
}
|
||||
|
||||
public function testCopyDuplicatesFile(): void
|
||||
{
|
||||
$from = 'uploads/policy/policy_pdf/source.pdf';
|
||||
$to = 'uploads/policy/policy_pdf/copy.pdf';
|
||||
$this->driver->put($from, 'copy-me');
|
||||
|
||||
$this->assertTrue($this->driver->copy($from, $to));
|
||||
$this->assertSame('copy-me', $this->driver->read($to));
|
||||
}
|
||||
|
||||
public function testTemporaryUrlUsesBaseUrlForLocalDriver(): void
|
||||
{
|
||||
$key = 'uploads/policy/policy_pdf/url.pdf';
|
||||
$this->driver->put($key, 'url');
|
||||
|
||||
$url = $this->driver->temporaryUrl($key);
|
||||
|
||||
$this->assertStringContainsString('uploads/policy/policy_pdf/url.pdf', $url);
|
||||
}
|
||||
|
||||
public function testStreamDownloadReturnsResponseWithBody(): void
|
||||
{
|
||||
$key = 'uploads/endorsement/endorsement_pdf/completion.pdf';
|
||||
$this->driver->put($key, 'completion pdf');
|
||||
|
||||
$response = $this->driver->streamDownload($key, 'completion.pdf');
|
||||
|
||||
$this->assertInstanceOf(DownloadResponse::class, $response);
|
||||
$this->assertDownloadHeaders($response, 'attachment');
|
||||
$this->assertSame(strlen('completion pdf'), $response->getContentLength());
|
||||
}
|
||||
}
|
||||
179
tests/unit/Storage/PolicyStorageHelperTest.php
Normal file
179
tests/unit/Storage/PolicyStorageHelperTest.php
Normal file
@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Storage;
|
||||
|
||||
use CodeIgniter\HTTP\DownloadResponse;
|
||||
use InvalidArgumentException;
|
||||
use Tests\Support\Storage\StorageTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class PolicyStorageHelperTest extends StorageTestCase
|
||||
{
|
||||
public function testPolicyFileTypeMapContainsExpectedTypes(): void
|
||||
{
|
||||
$map = policy_file_type_map();
|
||||
|
||||
$this->assertArrayHasKey('policy_pdf', $map);
|
||||
$this->assertArrayHasKey('policy_payment_receipt', $map);
|
||||
$this->assertSame('policy_pdf', $map['policy_pdf']['folder']);
|
||||
$this->assertSame('policy_payment_receipt', $map['policy_payment_receipt']['folder']);
|
||||
}
|
||||
|
||||
public function testPolicyMdFileNameConvertsPdfToMd(): void
|
||||
{
|
||||
$this->assertSame('1764999905_abc.md', policy_md_file_name('1764999905_abc.pdf'));
|
||||
$this->assertSame('file.md', policy_md_file_name('file'));
|
||||
}
|
||||
|
||||
public function testPolicyUploadPdfStoresFile(): void
|
||||
{
|
||||
$file = $this->createUploadedFile('%PDF policy', 'policy.pdf', 'application/pdf');
|
||||
|
||||
$stored = policy_upload_pdf($file);
|
||||
|
||||
$this->assertNotNull($stored);
|
||||
$this->assertTrue(policy_exists_by_type($stored, 'policy_pdf'));
|
||||
}
|
||||
|
||||
public function testPolicyUploadReceiptStoresFile(): void
|
||||
{
|
||||
$file = $this->createUploadedFile('receipt image', 'receipt.jpg', 'image/jpeg');
|
||||
|
||||
$stored = policy_upload_receipt($file);
|
||||
|
||||
$this->assertNotNull($stored);
|
||||
$this->assertTrue(policy_exists_by_type($stored, 'policy_payment_receipt'));
|
||||
}
|
||||
|
||||
public function testPolicyUploadReturnsNullWhenNoFile(): void
|
||||
{
|
||||
$this->assertNull(policy_upload_pdf(null));
|
||||
$this->assertNull(policy_upload_receipt(null));
|
||||
}
|
||||
|
||||
public function testPolicyExistsByTypeReturnsFalseForInvalidType(): void
|
||||
{
|
||||
$this->assertFalse(policy_exists_by_type('any.pdf', 'invalid_type'));
|
||||
}
|
||||
|
||||
public function testPolicyDownloadByTypeAttachment(): void
|
||||
{
|
||||
storage_put('policy', 'policy_pdf', 'download.pdf', 'policy-body');
|
||||
|
||||
$response = policy_download_by_type('download.pdf', 'policy_pdf');
|
||||
|
||||
$this->assertInstanceOf(DownloadResponse::class, $response);
|
||||
$this->assertDownloadHeaders($response, 'attachment');
|
||||
$this->assertGreaterThan(0, $response->getContentLength());
|
||||
}
|
||||
|
||||
public function testPolicyDownloadByTypeInline(): void
|
||||
{
|
||||
storage_put('policy', 'policy_payment_receipt', 'receipt.pdf', 'receipt-body');
|
||||
|
||||
$response = policy_download_by_type('receipt.pdf', 'policy_payment_receipt', true);
|
||||
|
||||
$this->assertInstanceOf(DownloadResponse::class, $response);
|
||||
$this->assertDownloadHeaders($response, 'inline');
|
||||
$this->assertGreaterThan(0, $response->getContentLength());
|
||||
}
|
||||
|
||||
public function testPolicyDownloadByTypeThrowsForInvalidType(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
policy_download_by_type('file.pdf', 'unknown');
|
||||
}
|
||||
|
||||
public function testPolicyLocalPdfPathResolvesStoredPdf(): void
|
||||
{
|
||||
storage_put('policy', 'policy_pdf', 'rag.pdf', 'rag-pdf');
|
||||
|
||||
$path = policy_local_pdf_path('rag.pdf');
|
||||
|
||||
$this->assertFileExists($path);
|
||||
$this->assertSame('rag-pdf', file_get_contents($path));
|
||||
}
|
||||
|
||||
public function testPolicyMarkdownPutReadAndExists(): void
|
||||
{
|
||||
policy_put_md('sample.md', str_repeat('x', 600));
|
||||
|
||||
$this->assertTrue(policy_md_exists('sample.md'));
|
||||
$this->assertSame(str_repeat('x', 600), policy_read_md('sample.md'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EndorsementStorageHelperTest extends StorageTestCase
|
||||
{
|
||||
public function testEndorsementSubfolderForType(): void
|
||||
{
|
||||
$this->assertSame('', endorsement_subfolder_for_type('original'));
|
||||
$this->assertSame('', endorsement_subfolder_for_type(null));
|
||||
$this->assertSame('endorsement_pdf', endorsement_subfolder_for_type('completion'));
|
||||
}
|
||||
|
||||
public function testEndorsementUploadOriginalStoresInRootFolder(): void
|
||||
{
|
||||
$file = $this->createUploadedFile('original endorsement', 'endorsement.pdf', 'application/pdf');
|
||||
|
||||
$stored = endorsement_upload_original($file);
|
||||
|
||||
$this->assertNotNull($stored);
|
||||
$this->assertTrue(endorsement_exists_by_type($stored, 'original'));
|
||||
$this->assertFileExists($this->localFilePath('uploads/endorsement/' . $stored));
|
||||
}
|
||||
|
||||
public function testEndorsementUploadCompletionStoresInSubfolder(): void
|
||||
{
|
||||
$file = $this->createUploadedFile('completion endorsement', 'completion.pdf', 'application/pdf');
|
||||
|
||||
$stored = endorsement_upload_completion($file);
|
||||
|
||||
$this->assertNotNull($stored);
|
||||
$this->assertTrue(endorsement_exists_by_type($stored, 'completion'));
|
||||
$this->assertFileExists($this->localFilePath('uploads/endorsement/endorsement_pdf/' . $stored));
|
||||
}
|
||||
|
||||
public function testEndorsementDownloadOriginalAndCompletion(): void
|
||||
{
|
||||
storage_put('endorsement', '', 'orig.pdf', 'orig-body');
|
||||
storage_put('endorsement', 'endorsement_pdf', 'done.pdf', 'done-body');
|
||||
|
||||
$originalResponse = endorsement_download_by_type('orig.pdf', 'original');
|
||||
$completionResponse = endorsement_download_by_type('done.pdf', 'completion');
|
||||
|
||||
$this->assertInstanceOf(DownloadResponse::class, $originalResponse);
|
||||
$this->assertInstanceOf(DownloadResponse::class, $completionResponse);
|
||||
$this->assertGreaterThan(0, $originalResponse->getContentLength());
|
||||
$this->assertGreaterThan(0, $completionResponse->getContentLength());
|
||||
}
|
||||
|
||||
public function testEndorsementDownloadInlineUsesInlineDisposition(): void
|
||||
{
|
||||
storage_put('endorsement', 'endorsement_pdf', 'view.pdf', 'view-body');
|
||||
|
||||
$response = endorsement_download_by_type('view.pdf', 'completion', true);
|
||||
|
||||
$this->assertStringContainsString('inline', $response->getHeaderLine('Content-Disposition'));
|
||||
}
|
||||
|
||||
public function testEndorsementDeleteCompletionRemovesFile(): void
|
||||
{
|
||||
storage_put('endorsement', 'endorsement_pdf', 'old.pdf', 'old');
|
||||
|
||||
$this->assertTrue(endorsement_delete_completion('old.pdf'));
|
||||
$this->assertFalse(endorsement_exists_by_type('old.pdf', 'completion'));
|
||||
}
|
||||
|
||||
public function testEndorsementUploadReturnsNullWhenNoFile(): void
|
||||
{
|
||||
$this->assertNull(endorsement_upload_original(null));
|
||||
$this->assertNull(endorsement_upload_completion(null));
|
||||
}
|
||||
}
|
||||
191
tests/unit/Storage/S3StorageIntegrationTest.php
Normal file
191
tests/unit/Storage/S3StorageIntegrationTest.php
Normal file
@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Storage;
|
||||
|
||||
use Tests\Support\Storage\StorageTestCase;
|
||||
|
||||
/**
|
||||
* Optional live S3 integration tests.
|
||||
*
|
||||
* Runs only when .env (or environment) has:
|
||||
* FILE_STORAGE_DRIVER=s3, AWS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
||||
*
|
||||
* @internal
|
||||
* @group s3-integration
|
||||
*/
|
||||
final class S3StorageIntegrationTest extends StorageTestCase
|
||||
{
|
||||
private ?string $uploadedPolicyKey = null;
|
||||
|
||||
private ?string $uploadedEndorsementKey = null;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->s3IntegrationEnabled()) {
|
||||
$this->useS3DriverFromEnv();
|
||||
|
||||
if ($this->uploadedPolicyKey !== null) {
|
||||
@storage()->deleteKey($this->uploadedPolicyKey);
|
||||
}
|
||||
|
||||
if ($this->uploadedEndorsementKey !== null) {
|
||||
@storage()->deleteKey($this->uploadedEndorsementKey);
|
||||
}
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testS3DriverIsActiveFromEnv(): void
|
||||
{
|
||||
$this->skipUnlessS3Integration();
|
||||
|
||||
$this->assertTrue(storage()->isS3());
|
||||
}
|
||||
|
||||
public function testS3PolicyPdfUploadExistsReadAndDelete(): void
|
||||
{
|
||||
$this->skipUnlessS3Integration();
|
||||
|
||||
$fileName = 'test_policy_' . uniqid('', true) . '.pdf';
|
||||
$file = $this->createUploadedFile('%PDF-1.4 test policy', $fileName, 'application/pdf');
|
||||
|
||||
$stored = policy_upload_pdf($file);
|
||||
|
||||
$this->assertIsString($stored);
|
||||
$this->assertNotSame('', $stored);
|
||||
$this->uploadedPolicyKey = storage()->resolveKey('policy', 'policy_pdf', $stored);
|
||||
$this->assertTrue(policy_exists_by_type($stored, 'policy_pdf'));
|
||||
$this->assertSame('%PDF-1.4 test policy', storage_read('policy', 'policy_pdf', $stored));
|
||||
|
||||
$tempPath = policy_local_pdf_path($stored);
|
||||
$this->assertFileExists($tempPath);
|
||||
|
||||
$this->assertTrue(storage_delete('policy', 'policy_pdf', $stored));
|
||||
$this->uploadedPolicyKey = null;
|
||||
$this->assertFalse(policy_exists_by_type($stored, 'policy_pdf'));
|
||||
}
|
||||
|
||||
public function testS3PolicyReceiptUploadAndTemporaryUrl(): void
|
||||
{
|
||||
$this->skipUnlessS3Integration();
|
||||
|
||||
$fileName = 'test_receipt_' . uniqid('', true) . '.jpg';
|
||||
$file = $this->createUploadedFile('receipt-image-bytes', $fileName, 'image/jpeg');
|
||||
|
||||
$stored = policy_upload_receipt($file);
|
||||
|
||||
$this->assertIsString($stored);
|
||||
$this->assertNotSame('', $stored);
|
||||
$this->uploadedPolicyKey = storage()->resolveKey('policy', 'policy_payment_receipt', $stored);
|
||||
$this->assertTrue(policy_exists_by_type($stored, 'policy_payment_receipt'));
|
||||
|
||||
$url = storage_temporary_url('policy', 'policy_payment_receipt', $stored);
|
||||
$this->assertStringStartsWith('https://', $url);
|
||||
}
|
||||
|
||||
public function testS3PolicyMarkdownPutAndRead(): void
|
||||
{
|
||||
$this->skipUnlessS3Integration();
|
||||
|
||||
$mdName = 'test_' . uniqid('', true) . '.md';
|
||||
$content = str_repeat('policy markdown ', 40);
|
||||
|
||||
policy_put_md($mdName, $content);
|
||||
$this->uploadedPolicyKey = storage()->resolveKey('policy', 'policy_md', $mdName);
|
||||
|
||||
$this->assertTrue(policy_md_exists($mdName));
|
||||
$this->assertSame($content, policy_read_md($mdName));
|
||||
}
|
||||
|
||||
public function testS3EndorsementOriginalAndCompletionUpload(): void
|
||||
{
|
||||
$this->skipUnlessS3Integration();
|
||||
|
||||
$originalFile = $this->createUploadedFile('original', 'orig.pdf', 'application/pdf');
|
||||
$completionFile = $this->createUploadedFile('completion', 'comp.pdf', 'application/pdf');
|
||||
|
||||
$original = endorsement_upload_original($originalFile);
|
||||
$completion = endorsement_upload_completion($completionFile);
|
||||
|
||||
$this->assertIsString($original);
|
||||
$this->assertIsString($completion);
|
||||
$this->uploadedEndorsementKey = storage()->resolveKey('endorsement', '', $original);
|
||||
$this->assertTrue(endorsement_exists_by_type($original, 'original'));
|
||||
$this->assertTrue(endorsement_exists_by_type($completion, 'completion'));
|
||||
|
||||
$response = endorsement_download_by_type($completion, 'completion', true);
|
||||
$this->assertStringContainsString('inline', $response->getHeaderLine('Content-Disposition'));
|
||||
}
|
||||
|
||||
public function testLocalDriverScenarioStillWorksWhenEnvIsLocal(): void
|
||||
{
|
||||
if (getenv('FILE_STORAGE_DRIVER') === 's3') {
|
||||
$this->markTestSkipped('This scenario validates explicit local override; current env is s3.');
|
||||
}
|
||||
|
||||
$this->useLocalDriver();
|
||||
|
||||
$stored = policy_upload_pdf(
|
||||
$this->createUploadedFile('local pdf', 'local.pdf', 'application/pdf')
|
||||
);
|
||||
|
||||
$this->assertNotNull($stored);
|
||||
$this->assertTrue(policy_exists_by_type($stored, 'policy_pdf'));
|
||||
$this->assertFileExists($this->localFilePath(storage()->resolveKey('policy', 'policy_pdf', $stored)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class StorageScenarioMatrixTest extends StorageTestCase
|
||||
{
|
||||
public function testAllPolicyFoldersCanStoreAndRetrieve(): void
|
||||
{
|
||||
$scenarios = [
|
||||
['policy_pdf', 'policy.pdf', 'pdf-content'],
|
||||
['policy_payment_receipt', 'receipt.jpg', 'receipt-content'],
|
||||
['policy_md', 'policy.md', str_repeat('md ', 200)],
|
||||
];
|
||||
|
||||
foreach ($scenarios as [$folder, $name, $contents]) {
|
||||
storage_put('policy', $folder, $name, $contents);
|
||||
|
||||
$this->assertTrue(storage_exists('policy', $folder, $name), "Missing {$folder}/{$name}");
|
||||
$this->assertSame($contents, storage_read('policy', $folder, $name), "Read failed {$folder}/{$name}");
|
||||
}
|
||||
}
|
||||
|
||||
public function testAllEndorsementFoldersCanStoreAndRetrieve(): void
|
||||
{
|
||||
storage_put('endorsement', '', 'original.pdf', 'original');
|
||||
storage_put('endorsement', 'endorsement_pdf', 'completion.pdf', 'completion');
|
||||
|
||||
$this->assertSame('original', storage_read('endorsement', '', 'original.pdf'));
|
||||
$this->assertSame('completion', storage_read('endorsement', 'endorsement_pdf', 'completion.pdf'));
|
||||
}
|
||||
|
||||
public function testMissingFileDownloadScenarioThrowsOrFailsExistsCheck(): void
|
||||
{
|
||||
$this->assertFalse(policy_exists_by_type('missing.pdf', 'policy_pdf'));
|
||||
$this->assertFalse(endorsement_exists_by_type('missing.pdf', 'original'));
|
||||
$this->assertFalse(endorsement_exists_by_type('missing.pdf', 'completion'));
|
||||
}
|
||||
|
||||
public function testReplaceEndorsementCompletionDeletesOldFile(): void
|
||||
{
|
||||
$oldName = endorsement_upload_completion(
|
||||
$this->createUploadedFile('old completion', 'old.pdf', 'application/pdf')
|
||||
);
|
||||
$this->assertTrue(endorsement_exists_by_type($oldName, 'completion'));
|
||||
|
||||
endorsement_delete_completion($oldName);
|
||||
$this->assertFalse(endorsement_exists_by_type($oldName, 'completion'));
|
||||
|
||||
$newName = endorsement_upload_completion(
|
||||
$this->createUploadedFile('new completion', 'new.pdf', 'application/pdf')
|
||||
);
|
||||
$this->assertTrue(endorsement_exists_by_type($newName, 'completion'));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
%PDF-1.4 endorsement original test
|
||||
@ -0,0 +1 @@
|
||||
%PDF-1.4 endorsement completion test
|
||||
Loading…
Reference in New Issue
Block a user