nhance_partner_be/app/Services/FileStorageService.php
2026-08-05 10:30:55 +05:30

522 lines
17 KiB
PHP

<?php
namespace App\Services;
use App\Services\Storage\FileStorageInterface;
use App\Services\Storage\LocalStorageDriver;
use App\Services\Storage\S3StorageDriver;
use App\Services\Storage\StorageLogger;
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');
StorageLogger::log('SUCCESS', 'File storage service initializing', $this->baseContext());
try {
$this->driver = $this->createDriver($this->config->driver);
StorageLogger::log('SUCCESS', 'File storage service initialized', $this->baseContext([
'retain_local' => $this->config->retainLocal,
]));
} catch (\Throwable $e) {
StorageLogger::log('FAILURE', 'File storage service initialization failed', $this->baseContext([
'error' => $e->getMessage(),
]));
throw $e;
}
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
private function baseContext(array $extra = []): array
{
return array_merge([
'driver' => $this->config->driver,
'bucket' => $this->config->s3Bucket !== '' ? $this->config->s3Bucket : null,
], $extra);
}
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
{
StorageLogger::log('SUCCESS', 'Service upload started', $this->baseContext([
'key' => $key,
'source' => $source instanceof UploadedFile ? 'uploaded_file' : 'path',
]));
try {
$storedKey = $this->driver->upload($source, $key, $options);
if ($this->config->retainLocal && $this->isS3()) {
StorageLogger::log('SUCCESS', 'Retain local copy started', $this->baseContext([
'key' => $key,
]));
try {
(new LocalStorageDriver($this->config))->upload($source, $key, $options);
StorageLogger::log('SUCCESS', 'Retain local copy complete', $this->baseContext([
'key' => $key,
]));
} catch (\Throwable $e) {
StorageLogger::log('WARNING', 'Retain local copy failed after primary upload', $this->baseContext([
'key' => $key,
'error' => $e->getMessage(),
]));
}
}
StorageLogger::log('SUCCESS', 'Service upload complete', $this->baseContext([
'key' => $storedKey,
]));
return $storedKey;
} catch (\Throwable $e) {
StorageLogger::log('FAILURE', 'Service upload failed', $this->baseContext([
'key' => $key,
'error' => $e->getMessage(),
]));
throw $e;
}
}
public function uploadModuleFile(
UploadedFile $file,
string $module,
string $subFolder = '',
?string $fileName = null
): string {
$fileName = $fileName ?: (time() . '_' . $file->getRandomName());
$key = $this->resolveKey($module, $subFolder, $fileName);
StorageLogger::log('SUCCESS', 'Module upload started', $this->baseContext([
'module' => $module,
'sub_folder' => $subFolder,
'file_name' => $fileName,
'key' => $key,
'original_name' => $file->getClientName(),
'client_mime' => $file->getClientMimeType(),
'size' => $file->getSize(),
]));
$this->upload($file, $key);
StorageLogger::log('SUCCESS', 'Module upload complete', $this->baseContext([
'module' => $module,
'sub_folder' => $subFolder,
'file_name' => $fileName,
'key' => $key,
]));
return $fileName;
}
public function put(string $key, string $contents, array $options = []): string
{
StorageLogger::log('SUCCESS', 'Service put started', $this->baseContext([
'key' => $key,
'size' => strlen($contents),
]));
try {
$storedKey = $this->driver->put($key, $contents, $options);
if ($this->config->retainLocal && $this->isS3()) {
StorageLogger::log('SUCCESS', 'Retain local put started', $this->baseContext([
'key' => $key,
]));
try {
(new LocalStorageDriver($this->config))->put($key, $contents, $options);
StorageLogger::log('SUCCESS', 'Retain local put complete', $this->baseContext([
'key' => $key,
]));
} catch (\Throwable $e) {
StorageLogger::log('WARNING', 'Retain local put failed after primary put', $this->baseContext([
'key' => $key,
'error' => $e->getMessage(),
]));
}
}
StorageLogger::log('SUCCESS', 'Service put complete', $this->baseContext([
'key' => $storedKey,
]));
return $storedKey;
} catch (\Throwable $e) {
StorageLogger::log('FAILURE', 'Service put failed', $this->baseContext([
'key' => $key,
'error' => $e->getMessage(),
]));
throw $e;
}
}
public function exists(string $module, string $subFolder, string $fileName): bool
{
$key = $this->resolveKey($module, $subFolder, $fileName);
$exists = $this->driver->exists($key);
StorageLogger::log('SUCCESS', 'Service exists check complete', $this->baseContext([
'module' => $module,
'sub_folder' => $subFolder,
'file_name' => $fileName,
'key' => $key,
'exists' => $exists,
]));
return $exists;
}
public function existsKey(string $key): bool
{
$exists = $this->driver->exists($key);
StorageLogger::log('SUCCESS', 'Service exists key check complete', $this->baseContext([
'key' => $key,
'exists' => $exists,
]));
return $exists;
}
public function delete(string $module, string $subFolder, string $fileName): bool
{
$key = $this->resolveKey($module, $subFolder, $fileName);
$deleted = $this->driver->delete($key);
StorageLogger::log($deleted ? 'SUCCESS' : 'WARNING', 'Service delete complete', $this->baseContext([
'module' => $module,
'sub_folder' => $subFolder,
'file_name' => $fileName,
'key' => $key,
'deleted' => $deleted,
]));
return $deleted;
}
public function deleteKey(string $key): bool
{
$deleted = $this->driver->delete($key);
StorageLogger::log($deleted ? 'SUCCESS' : 'WARNING', 'Service delete key complete', $this->baseContext([
'key' => $key,
'deleted' => $deleted,
]));
return $deleted;
}
public function read(string $module, string $subFolder, string $fileName): string
{
$key = $this->resolveKey($module, $subFolder, $fileName);
StorageLogger::log('SUCCESS', 'Service read started', $this->baseContext([
'module' => $module,
'sub_folder' => $subFolder,
'file_name' => $fileName,
'key' => $key,
]));
try {
$contents = $this->driver->read($key);
StorageLogger::log('SUCCESS', 'Service read complete', $this->baseContext([
'key' => $key,
'size' => strlen($contents),
]));
return $contents;
} catch (\Throwable $e) {
StorageLogger::log('FAILURE', 'Service read failed', $this->baseContext([
'key' => $key,
'error' => $e->getMessage(),
]));
throw $e;
}
}
public function readKey(string $key): string
{
StorageLogger::log('SUCCESS', 'Service read key started', $this->baseContext([
'key' => $key,
]));
try {
$contents = $this->driver->read($key);
StorageLogger::log('SUCCESS', 'Service read key complete', $this->baseContext([
'key' => $key,
'size' => strlen($contents),
]));
return $contents;
} catch (\Throwable $e) {
StorageLogger::log('FAILURE', 'Service read key failed', $this->baseContext([
'key' => $key,
'error' => $e->getMessage(),
]));
throw $e;
}
}
public function download(string $module, string $subFolder, string $fileName): ResponseInterface
{
$key = $this->resolveKey($module, $subFolder, $fileName);
StorageLogger::log('SUCCESS', 'Service download started', $this->baseContext([
'module' => $module,
'sub_folder' => $subFolder,
'file_name' => $fileName,
'key' => $key,
]));
try {
$response = $this->driver->streamDownload($key, $fileName);
StorageLogger::log('SUCCESS', 'Service download complete', $this->baseContext([
'key' => $key,
'file_name' => $fileName,
]));
return $response;
} catch (\Throwable $e) {
StorageLogger::log('FAILURE', 'Service download failed', $this->baseContext([
'key' => $key,
'error' => $e->getMessage(),
]));
throw $e;
}
}
public function downloadKey(string $key, ?string $downloadName = null): ResponseInterface
{
StorageLogger::log('SUCCESS', 'Service download key started', $this->baseContext([
'key' => $key,
'download_name' => $downloadName ?: basename($key),
]));
try {
$response = $this->driver->streamDownload($key, $downloadName ?: basename($key));
StorageLogger::log('SUCCESS', 'Service download key complete', $this->baseContext([
'key' => $key,
'download_name' => $downloadName ?: basename($key),
]));
return $response;
} catch (\Throwable $e) {
StorageLogger::log('FAILURE', 'Service download key failed', $this->baseContext([
'key' => $key,
'error' => $e->getMessage(),
]));
throw $e;
}
}
public function getLocalPathForProcessing(string $module, string $subFolder, string $fileName): string
{
return $this->getLocalPathForKey($this->resolveKey($module, $subFolder, $fileName));
}
public function getLocalPathForKey(string $key): string
{
StorageLogger::log('SUCCESS', 'Service local path resolution started', $this->baseContext([
'key' => $key,
]));
try {
if ($this->config->driver === 'local') {
$path = rtrim($this->config->localRoot, '/\\') . DIRECTORY_SEPARATOR . ltrim(str_replace('/', DIRECTORY_SEPARATOR, $key), '/\\');
} else {
$path = $this->driver->downloadToTemp($key);
}
StorageLogger::log('SUCCESS', 'Service local path resolution complete', $this->baseContext([
'key' => $key,
'path' => $path,
]));
return $path;
} catch (\Throwable $e) {
StorageLogger::log('FAILURE', 'Service local path resolution failed', $this->baseContext([
'key' => $key,
'error' => $e->getMessage(),
]));
throw $e;
}
}
/**
* Delete a path returned by getLocalPathForProcessing / downloadToTemp when it lives
* under the OS temp directory (S3 working copies). Never deletes local writable/uploads files.
*/
public function cleanupTempPath(?string $path): bool
{
if ($path === null || $path === '') {
return false;
}
if (! is_file($path)) {
return false;
}
if (! $this->isS3TempProcessingPath($path)) {
return false;
}
$deleted = @unlink($path);
StorageLogger::log($deleted ? 'SUCCESS' : 'WARNING', 'Temp processing path cleanup', $this->baseContext([
'path' => $path,
'deleted' => $deleted,
]));
return $deleted;
}
/**
* True for S3 downloadToTemp paths like /tmp/s3_<uniqid>_<basename>.
*/
public function isS3TempProcessingPath(string $path): bool
{
$tempRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, sys_get_temp_dir()), DIRECTORY_SEPARATOR);
$normalized = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
$realTemp = realpath(sys_get_temp_dir());
if ($realTemp !== false) {
$tempRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $realTemp), DIRECTORY_SEPARATOR);
}
$realPath = realpath($path);
$checkPath = $realPath !== false
? str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $realPath)
: $normalized;
if (! str_starts_with($checkPath, $tempRoot . DIRECTORY_SEPARATOR)) {
return false;
}
return str_starts_with(basename($checkPath), 's3_');
}
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
{
StorageLogger::log('SUCCESS', 'Service temporary URL started', $this->baseContext([
'key' => $key,
'ttl_seconds' => $ttlSeconds ?? $this->config->presignedTtl,
]));
try {
$url = $this->driver->temporaryUrl($key, $ttlSeconds ?? $this->config->presignedTtl);
StorageLogger::log('SUCCESS', 'Service temporary URL complete', $this->baseContext([
'key' => $key,
'ttl_seconds' => $ttlSeconds ?? $this->config->presignedTtl,
]));
return $url;
} catch (\Throwable $e) {
StorageLogger::log('FAILURE', 'Service temporary URL failed', $this->baseContext([
'key' => $key,
'error' => $e->getMessage(),
]));
throw $e;
}
}
public function getUrl(string $module, string $subFolder, string $fileName): string
{
$key = $this->resolveKey($module, $subFolder, $fileName);
$url = $this->driver->url($key);
StorageLogger::log('SUCCESS', 'Service URL resolved', $this->baseContext([
'module' => $module,
'sub_folder' => $subFolder,
'file_name' => $fileName,
'key' => $key,
]));
return $url;
}
public function copy(string $fromModule, string $fromSubFolder, string $fromFileName, string $toModule, string $toSubFolder, string $toFileName): bool
{
$fromKey = $this->resolveKey($fromModule, $fromSubFolder, $fromFileName);
$toKey = $this->resolveKey($toModule, $toSubFolder, $toFileName);
StorageLogger::log('SUCCESS', 'Service copy started', $this->baseContext([
'from_key' => $fromKey,
'to_key' => $toKey,
]));
try {
$copied = $this->driver->copy($fromKey, $toKey);
StorageLogger::log($copied ? 'SUCCESS' : 'FAILURE', 'Service copy complete', $this->baseContext([
'from_key' => $fromKey,
'to_key' => $toKey,
'copied' => $copied,
]));
return $copied;
} catch (\Throwable $e) {
StorageLogger::log('FAILURE', 'Service copy failed', $this->baseContext([
'from_key' => $fromKey,
'to_key' => $toKey,
'error' => $e->getMessage(),
]));
throw $e;
}
}
private function createDriver(string $driver): FileStorageInterface
{
return match ($driver) {
's3' => new S3StorageDriver($this->config),
default => new LocalStorageDriver($this->config),
};
}
}