Files
ladill-transfer/app/Services/Upload/ChunkedUploadService.php
T
isaaccladandCursor 65b634bb0b
Deploy Ladill Transfer / deploy (push) Successful in 39s
Add chunked uploads and raise storage rate to GHS 0.30/GB/month.
Large files upload in 5 MB chunks (hosting file manager pattern) for the
transfer UI and Ladill Mail S2S API, with no app-level file size cap when
TRANSFER_MAX_FILE_BYTES=0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 12:07:27 +00:00

193 lines
6.2 KiB
PHP

<?php
namespace App\Services\Upload;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use RuntimeException;
/** Bit-sized upload sessions (init → chunks → finalize), matching hosting file manager. */
class ChunkedUploadService
{
public function baseDir(): string
{
$dir = (string) config('transfer.chunk_upload.path', storage_path('app/chunked_uploads'));
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $dir;
}
/** @param array<string, mixed> $metadata */
public function init(string $ownerKey, array $metadata): string
{
$uploadId = (string) Str::uuid();
$tempDir = $this->sessionDir($uploadId);
if (! is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
$payload = array_merge($metadata, [
'owner_key' => $ownerKey,
'chunks_received' => [],
'created_at' => now()->toIso8601String(),
]);
$this->writeMetadata($uploadId, $payload);
return $uploadId;
}
public function receiveChunk(string $uploadId, string $ownerKey, int $chunkIndex, UploadedFile $chunk, int $expectedSize): int
{
$metadata = $this->metadata($uploadId, $ownerKey);
$tempDir = $this->sessionDir($uploadId);
$actualSize = (int) $chunk->getSize();
if ($expectedSize > 0 && $actualSize !== $expectedSize) {
throw new RuntimeException("Chunk size mismatch: expected {$expectedSize}, got {$actualSize}.");
}
$chunkPath = "{$tempDir}/chunk_{$chunkIndex}";
$chunk->move($tempDir, "chunk_{$chunkIndex}");
if (! file_exists($chunkPath) || filesize($chunkPath) !== $actualSize) {
throw new RuntimeException('Chunk save failed.');
}
$metadata['chunks_received'][] = $chunkIndex;
$metadata['chunks_received'] = array_values(array_unique($metadata['chunks_received']));
sort($metadata['chunks_received']);
$this->writeMetadata($uploadId, $metadata);
return count($metadata['chunks_received']);
}
/** @return array{path: string, filename: string, size: int, metadata: array<string, mixed>} */
public function finalize(string $uploadId, string $ownerKey, int $totalChunks): array
{
$metadata = $this->metadata($uploadId, $ownerKey);
$tempDir = $this->sessionDir($uploadId);
if (count($metadata['chunks_received']) !== $totalChunks) {
throw new RuntimeException(
'Missing chunks. Expected '.$totalChunks.', received '.count($metadata['chunks_received']).'.',
);
}
$finalPath = "{$tempDir}/final_".Str::random(8);
$finalFile = fopen($finalPath, 'wb');
if ($finalFile === false) {
throw new RuntimeException('Could not create assembled file.');
}
for ($i = 0; $i < $totalChunks; $i++) {
$chunkPath = "{$tempDir}/chunk_{$i}";
if (! file_exists($chunkPath)) {
fclose($finalFile);
throw new RuntimeException("Chunk {$i} is missing.");
}
$chunkHandle = fopen($chunkPath, 'rb');
if ($chunkHandle === false) {
fclose($finalFile);
throw new RuntimeException("Could not read chunk {$i}.");
}
while (! feof($chunkHandle)) {
$buffer = fread($chunkHandle, 8192);
if ($buffer === false) {
break;
}
fwrite($finalFile, $buffer);
}
fclose($chunkHandle);
}
fclose($finalFile);
$combinedSize = (int) filesize($finalPath);
$expectedSize = (int) ($metadata['filesize'] ?? 0);
if ($expectedSize > 0 && abs($combinedSize - $expectedSize) > 1024) {
@unlink($finalPath);
throw new RuntimeException("File size mismatch. Expected {$expectedSize} bytes, got {$combinedSize} bytes.");
}
$metadata['assembled_path'] = $finalPath;
$metadata['assembled_size'] = $combinedSize;
$this->writeMetadata($uploadId, $metadata);
return [
'path' => $finalPath,
'filename' => basename((string) ($metadata['filename'] ?? 'upload.bin')),
'size' => $combinedSize,
'metadata' => $metadata,
];
}
public function toUploadedFile(string $uploadId, string $ownerKey): UploadedFile
{
$metadata = $this->metadata($uploadId, $ownerKey);
$path = (string) ($metadata['assembled_path'] ?? '');
if ($path === '' || ! is_file($path)) {
throw new RuntimeException('Assembled upload is not ready.');
}
return new UploadedFile(
$path,
basename((string) ($metadata['filename'] ?? 'upload.bin')),
null,
null,
true,
);
}
public function cleanup(string $uploadId): void
{
$tempDir = $this->sessionDir($uploadId);
if (! is_dir($tempDir)) {
return;
}
foreach (glob("{$tempDir}/*") ?: [] as $file) {
@unlink($file);
}
@rmdir($tempDir);
}
/** @return array<string, mixed> */
public function metadata(string $uploadId, string $ownerKey): array
{
$path = $this->metadataPath($uploadId);
if (! file_exists($path)) {
throw new RuntimeException('Upload session not found or expired.');
}
$metadata = json_decode((string) file_get_contents($path), true);
if (! is_array($metadata) || ($metadata['owner_key'] ?? '') !== $ownerKey) {
throw new RuntimeException('Upload session not found or unauthorized.');
}
return $metadata;
}
private function sessionDir(string $uploadId): string
{
return $this->baseDir().'/'.$uploadId;
}
private function metadataPath(string $uploadId): string
{
return $this->sessionDir($uploadId).'/metadata.json';
}
/** @param array<string, mixed> $metadata */
private function writeMetadata(string $uploadId, array $metadata): void
{
file_put_contents($this->metadataPath($uploadId), json_encode($metadata));
}
}