Add chunked uploads and raise storage rate to GHS 0.30/GB/month.
Deploy Ladill Transfer / deploy (push) Successful in 39s
Deploy Ladill Transfer / deploy (push) Successful in 39s
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>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user