Files
SkinbaseNova/app/Services/Uploads/UploadChunkService.php
2026-02-14 15:14:12 +01:00

205 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Uploads;
use App\DTOs\Uploads\UploadChunkResult;
use App\Repositories\Uploads\UploadSessionRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use RuntimeException;
final class UploadChunkService
{
public function __construct(
private readonly UploadStorageService $storage,
private readonly UploadSessionRepository $sessions,
private readonly UploadAuditService $audit
) {
}
public function appendChunk(string $sessionId, string $chunkPath, int $offset, int $chunkSize, int $totalSize, int $userId, string $ip): UploadChunkResult
{
$session = $this->sessions->getOrFail($sessionId);
$this->ensureTmpPath($session->tempPath);
$this->ensureWritable($session->tempPath);
$this->ensureChunkReadable($chunkPath, $chunkSize);
$this->ensureLimits($totalSize, $chunkSize);
$lockSeconds = (int) config('uploads.chunk.lock_seconds', 10);
$lockWait = (int) config('uploads.chunk.lock_wait_seconds', 5);
$lock = Cache::lock('uploads:chunk:' . $sessionId, $lockSeconds);
try {
$lock->block($lockWait);
} catch (\Throwable $e) {
$this->audit->log($userId, 'upload_chunk_locked', $ip, [
'session_id' => $sessionId,
]);
throw new RuntimeException('Upload is busy. Please retry.');
}
try {
$currentSize = (int) filesize($session->tempPath);
if ($offset > $currentSize) {
$this->audit->log($userId, 'upload_chunk_offset_mismatch', $ip, [
'session_id' => $sessionId,
'offset' => $offset,
'current_size' => $currentSize,
]);
throw new RuntimeException('Invalid chunk offset.');
}
if ($offset < $currentSize) {
if ($offset + $chunkSize <= $currentSize) {
return $this->finalizeResult($sessionId, $totalSize, $currentSize);
}
$this->audit->log($userId, 'upload_chunk_overlap', $ip, [
'session_id' => $sessionId,
'offset' => $offset,
'current_size' => $currentSize,
]);
throw new RuntimeException('Chunk overlap detected.');
}
$written = $this->appendToFile($session->tempPath, $chunkPath, $offset, $chunkSize);
$newSize = $currentSize + $written;
if ($newSize > $totalSize) {
$this->audit->log($userId, 'upload_chunk_size_exceeded', $ip, [
'session_id' => $sessionId,
'new_size' => $newSize,
'total_size' => $totalSize,
]);
throw new RuntimeException('Upload exceeded expected size.');
}
$this->sessions->updateStatus($sessionId, UploadSessionStatus::TMP);
$result = $this->finalizeResult($sessionId, $totalSize, $newSize);
$this->audit->log($userId, 'upload_chunk_appended', $ip, [
'session_id' => $sessionId,
'received_bytes' => $newSize,
'total_size' => $totalSize,
'progress' => $result->progress,
]);
return $result;
} finally {
optional($lock)->release();
}
}
private function finalizeResult(string $sessionId, int $totalSize, int $currentSize): UploadChunkResult
{
$progress = $totalSize > 0 ? (int) floor(($currentSize / $totalSize) * 100) : 0;
$progress = min(90, max(0, $progress));
$this->sessions->updateProgress($sessionId, $progress);
return new UploadChunkResult(
$sessionId,
UploadSessionStatus::TMP,
$currentSize,
$totalSize,
$progress
);
}
private function ensureTmpPath(string $path): void
{
$tmpRoot = $this->storage->sectionPath('tmp');
$realRoot = realpath($tmpRoot);
$realPath = realpath($path);
if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) {
throw new RuntimeException('Invalid temp path.');
}
}
private function ensureWritable(string $path): void
{
if (! File::exists($path)) {
File::put($path, '');
}
if (! is_writable($path)) {
throw new RuntimeException('Upload path not writable.');
}
}
private function ensureLimits(int $totalSize, int $chunkSize): void
{
$maxBytes = (int) config('uploads.max_size_mb', 0) * 1024 * 1024;
if ($maxBytes > 0 && $totalSize > $maxBytes) {
throw new RuntimeException('Upload exceeds max size.');
}
$maxChunk = (int) config('uploads.chunk.max_bytes', 0);
if ($maxChunk > 0 && $chunkSize > $maxChunk) {
throw new RuntimeException('Chunk exceeds max size.');
}
}
private function ensureChunkReadable(string $chunkPath, int $chunkSize): void
{
$exists = is_file($chunkPath);
$readable = $exists ? is_readable($chunkPath) : false;
$actualSize = $exists ? (int) @filesize($chunkPath) : null;
if (! $exists || ! $readable) {
logger()->warning('Upload chunk unreadable or missing', [
'chunk_path' => $chunkPath,
'expected_size' => $chunkSize,
'exists' => $exists,
'readable' => $readable,
'actual_size' => $actualSize,
]);
throw new RuntimeException('Upload chunk missing.');
}
if ($actualSize !== $chunkSize) {
logger()->warning('Upload chunk size mismatch', [
'chunk_path' => $chunkPath,
'expected_size' => $chunkSize,
'actual_size' => $actualSize,
]);
throw new RuntimeException('Chunk size mismatch.');
}
}
private function appendToFile(string $targetPath, string $chunkPath, int $offset, int $chunkSize): int
{
$in = fopen($chunkPath, 'rb');
if (! $in) {
throw new RuntimeException('Unable to read upload chunk.');
}
$out = fopen($targetPath, 'c+b');
if (! $out) {
fclose($in);
throw new RuntimeException('Unable to write upload chunk.');
}
if (fseek($out, $offset) !== 0) {
fclose($in);
fclose($out);
throw new RuntimeException('Failed to seek in upload file.');
}
$written = stream_copy_to_stream($in, $out, $chunkSize);
fflush($out);
fclose($in);
fclose($out);
if ($written === false || (int) $written !== $chunkSize) {
throw new RuntimeException('Incomplete chunk write.');
}
return (int) $written;
}
}