205 lines
6.9 KiB
PHP
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;
|
|
}
|
|
}
|