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>
193 lines
6.2 KiB
PHP
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));
|
|
}
|
|
}
|