522 lines
17 KiB
PHP
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),
|
|
};
|
|
}
|
|
}
|