From 65b634bb0b526bd3de4b80c8ed15185fdfdb7526 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 8 Jun 2026 12:07:27 +0000 Subject: [PATCH] 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 --- .env.example | 5 +- DEPLOY.md | 2 +- .../Api/ChunkedUploadController.php | 156 ++++++++++++++ .../Controllers/Api/TransferController.php | 8 +- app/Http/Controllers/Qr/AccountController.php | 2 +- app/Http/Controllers/Qr/AfiaController.php | 2 +- .../Transfer/ChunkedUploadController.php | 103 ++++++++++ .../Controllers/Transfer/FilesController.php | 2 +- .../Transfer/OverviewController.php | 2 +- .../Transfer/TransferController.php | 38 +++- app/Models/Transfer.php | 2 +- app/Services/Afia/AfiaService.php | 2 +- app/Services/Transfer/TransferService.php | 4 +- app/Services/Upload/ChunkedUploadService.php | 192 ++++++++++++++++++ config/transfer.php | 12 +- deploy/shared.env.example | 4 +- resources/js/app.js | 85 ++++++++ resources/js/chunked-upload.js | 121 +++++++++++ resources/views/transfer/dashboard.blade.php | 2 +- .../views/transfer/transfers/create.blade.php | 28 ++- routes/api.php | 4 + routes/web.php | 4 + tests/Feature/TransferApiTest.php | 44 ++++ 23 files changed, 797 insertions(+), 27 deletions(-) create mode 100644 app/Http/Controllers/Api/ChunkedUploadController.php create mode 100644 app/Http/Controllers/Transfer/ChunkedUploadController.php create mode 100644 app/Services/Upload/ChunkedUploadService.php create mode 100644 resources/js/chunked-upload.js diff --git a/.env.example b/.env.example index 0b3a756..60652d8 100644 --- a/.env.example +++ b/.env.example @@ -37,8 +37,9 @@ BILLING_API_KEY_TRANSFER= IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_KEY_TRANSFER= -TRANSFER_PRICE_PER_GB_MONTH=0.15 -TRANSFER_MAX_FILE_BYTES=524288000 +TRANSFER_PRICE_PER_GB_MONTH=0.30 +# 0 = no app-level file size cap (chunked uploads handle large files). +TRANSFER_MAX_FILE_BYTES=0 TRANSFER_MAX_FILES=20 TRANSFER_DEFAULT_RETENTION_DAYS=30 TRANSFER_MAIL_RETENTION_DAYS=30 diff --git a/DEPLOY.md b/DEPLOY.md index 5238652..9f7b2bf 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -2,7 +2,7 @@ Standalone app for **secure file sharing** at `transfer.ladill.com` — upload files, get a QR code + share link, control retention, track downloads. Storage bills at -**GHS 0.15/GB/month** from the platform UserWallet. +**GHS 0.30/GB/month** from the platform UserWallet. Public scans stay at `ladill.com/q/`. The platform forwards transfer codes to this app (`TransferQrForwarder` on the monolith). diff --git a/app/Http/Controllers/Api/ChunkedUploadController.php b/app/Http/Controllers/Api/ChunkedUploadController.php new file mode 100644 index 0000000..1fb58af --- /dev/null +++ b/app/Http/Controllers/Api/ChunkedUploadController.php @@ -0,0 +1,156 @@ +attributes->get('service_caller') !== 'webmail') { + return response()->json(['message' => 'Forbidden.'], 403); + } + + $validated = $request->validate([ + 'mailbox' => ['required', 'email', 'max:255'], + 'filename' => ['required', 'string', 'max:255'], + 'filesize' => ['required', 'integer', 'min:1'], + ]); + + $mailbox = strtolower(trim((string) $validated['mailbox'])); + + try { + $this->users->resolve($mailbox); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + $uploadId = $this->uploads->init($this->ownerKey($mailbox), [ + 'filename' => $validated['filename'], + 'filesize' => (int) $validated['filesize'], + 'mailbox' => $mailbox, + ]); + + return response()->json([ + 'success' => true, + 'upload_id' => $uploadId, + ]); + } + + public function chunk(Request $request): JsonResponse + { + if ($request->attributes->get('service_caller') !== 'webmail') { + return response()->json(['message' => 'Forbidden.'], 403); + } + + $validated = $request->validate([ + 'mailbox' => ['required', 'email', 'max:255'], + 'upload_id' => ['required', 'string', 'uuid'], + 'chunk_index' => ['required', 'integer', 'min:0'], + 'chunk_size' => ['required', 'integer', 'min:1'], + 'chunk' => ['required', 'file', 'max:10240'], + ]); + + $mailbox = strtolower(trim((string) $validated['mailbox'])); + $chunk = $request->file('chunk'); + if ($chunk === null) { + return response()->json(['success' => false, 'error' => 'Chunk missing.'], 422); + } + + try { + $received = $this->uploads->receiveChunk( + $validated['upload_id'], + $this->ownerKey($mailbox), + (int) $validated['chunk_index'], + $chunk, + (int) $validated['chunk_size'], + ); + } catch (RuntimeException $e) { + return response()->json(['success' => false, 'error' => $e->getMessage()], 400); + } + + return response()->json([ + 'success' => true, + 'chunk_index' => (int) $validated['chunk_index'], + 'chunks_received' => $received, + ]); + } + + public function finalize(Request $request): JsonResponse + { + if ($request->attributes->get('service_caller') !== 'webmail') { + return response()->json(['message' => 'Forbidden.'], 403); + } + + $validated = $request->validate([ + 'mailbox' => ['required', 'email', 'max:255'], + 'upload_id' => ['required', 'string', 'uuid'], + 'total_chunks' => ['required', 'integer', 'min:1'], + 'title' => ['nullable', 'string', 'max:120'], + 'retention_days' => ['nullable', 'integer', 'min:1', 'max:365'], + ]); + + $mailbox = strtolower(trim((string) $validated['mailbox'])); + $ownerKey = $this->ownerKey($mailbox); + + try { + $user = $this->users->resolve($mailbox); + $assembled = $this->uploads->finalize( + $validated['upload_id'], + $ownerKey, + (int) $validated['total_chunks'], + ); + $uploadedFile = $this->uploads->toUploadedFile($validated['upload_id'], $ownerKey); + + $title = trim((string) ($validated['title'] ?? '')); + if ($title === '') { + $title = Str::limit('Email attachment: '.$assembled['filename'], 120, ''); + } + + $retentionDays = (int) ($validated['retention_days'] ?? config('transfer.mail_retention_days', config('transfer.default_retention_days', 30))); + + $transfer = $this->transfers->create($user, [ + 'title' => $title, + 'retention_days' => $retentionDays, + ], [$uploadedFile]); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 422); + } finally { + $this->uploads->cleanup($validated['upload_id']); + } + + $transfer->load(['files', 'qrCode']); + + return response()->json([ + 'data' => [ + 'id' => $transfer->id, + 'title' => $transfer->title, + 'public_url' => $transfer->qrCode?->publicUrl(), + 'expires_at' => $transfer->expires_at?->toIso8601String(), + 'files' => $transfer->files->map(fn ($file) => [ + 'name' => $file->original_name, + 'size_bytes' => $file->size_bytes, + ])->values(), + ], + ], 201); + } + + private function ownerKey(string $mailbox): string + { + return 'webmail:'.strtolower(trim($mailbox)); + } +} diff --git a/app/Http/Controllers/Api/TransferController.php b/app/Http/Controllers/Api/TransferController.php index 8df4c6e..a56c205 100644 --- a/app/Http/Controllers/Api/TransferController.php +++ b/app/Http/Controllers/Api/TransferController.php @@ -23,8 +23,12 @@ class TransferController extends Controller return response()->json(['message' => 'Forbidden.'], 403); } - $maxKb = (int) ceil(((int) config('transfer.max_file_bytes', 524288000)) / 1024); + $maxBytes = (int) config('transfer.max_file_bytes', 0); $maxFiles = (int) config('transfer.max_files_per_transfer', 20); + $fileRules = ['required', 'file']; + if ($maxBytes > 0) { + $fileRules[] = 'max:'.(int) ceil($maxBytes / 1024); + } $data = $request->validate([ 'mailbox' => ['required', 'email', 'max:255'], @@ -32,7 +36,7 @@ class TransferController extends Controller 'message' => ['nullable', 'string', 'max:2000'], 'retention_days' => ['nullable', 'integer', 'min:1', 'max:365'], 'files' => ['required', 'array', 'min:1', 'max:'.$maxFiles], - 'files.*' => ['required', 'file', 'max:'.$maxKb], + 'files.*' => $fileRules, ]); try { diff --git a/app/Http/Controllers/Qr/AccountController.php b/app/Http/Controllers/Qr/AccountController.php index b5b3865..a2b9fbd 100644 --- a/app/Http/Controllers/Qr/AccountController.php +++ b/app/Http/Controllers/Qr/AccountController.php @@ -61,7 +61,7 @@ class AccountController extends Controller $storageBytes = (int) $activeTransfers->sum('storage_bytes'); $storageGb = round($storageBytes / (1024 * 1024 * 1024), 2); - $pricePerGb = (float) config('transfer.price_per_gb_month', 0.15); + $pricePerGb = (float) config('transfer.price_per_gb_month', 0.30); $monthlyTotalGhs = round($activeTransfers->sum(fn (Transfer $transfer) => $transfer->monthlyCostGhs()), 2); return view('qr.account.billing', [ diff --git a/app/Http/Controllers/Qr/AfiaController.php b/app/Http/Controllers/Qr/AfiaController.php index c1fcbc6..494fe11 100644 --- a/app/Http/Controllers/Qr/AfiaController.php +++ b/app/Http/Controllers/Qr/AfiaController.php @@ -70,7 +70,7 @@ class AfiaController extends Controller ->whereNotNull('expires_at') ->whereBetween('expires_at', [now(), now()->addDays(7)]) ->count(), - 'storage_price_per_gb_month_ghs' => number_format((float) config('transfer.price_per_gb_month', 0.15), 2), + 'storage_price_per_gb_month_ghs' => number_format((float) config('transfer.price_per_gb_month', 0.30), 2), ]; if ($account->public_id) { diff --git a/app/Http/Controllers/Transfer/ChunkedUploadController.php b/app/Http/Controllers/Transfer/ChunkedUploadController.php new file mode 100644 index 0000000..3d84f74 --- /dev/null +++ b/app/Http/Controllers/Transfer/ChunkedUploadController.php @@ -0,0 +1,103 @@ +validate([ + 'filename' => 'required|string|max:255', + 'filesize' => 'required|integer|min:1', + ]); + + $uploadId = $this->uploads->init($this->ownerKey($account->id), [ + 'filename' => $validated['filename'], + 'filesize' => (int) $validated['filesize'], + ]); + + return response()->json([ + 'success' => true, + 'upload_id' => $uploadId, + ]); + } + + public function chunk(Request $request): JsonResponse + { + $account = ladill_account(); + $ownerKey = $this->ownerKey($account->id); + + $validated = $request->validate([ + 'upload_id' => 'required|string|uuid', + 'chunk_index' => 'required|integer|min:0', + 'chunk_size' => 'required|integer|min:1', + 'chunk' => 'required|file|max:10240', + ]); + + $chunk = $request->file('chunk'); + if ($chunk === null) { + return response()->json(['success' => false, 'error' => 'Chunk missing.'], 422); + } + + try { + $received = $this->uploads->receiveChunk( + $validated['upload_id'], + $ownerKey, + (int) $validated['chunk_index'], + $chunk, + (int) $validated['chunk_size'], + ); + } catch (RuntimeException $e) { + return response()->json(['success' => false, 'error' => $e->getMessage()], 400); + } + + return response()->json([ + 'success' => true, + 'chunk_index' => (int) $validated['chunk_index'], + 'chunks_received' => $received, + ]); + } + + public function finalize(Request $request): JsonResponse + { + $account = ladill_account(); + $ownerKey = $this->ownerKey($account->id); + + $validated = $request->validate([ + 'upload_id' => 'required|string|uuid', + 'total_chunks' => 'required|integer|min:1', + ]); + + try { + $this->uploads->finalize( + $validated['upload_id'], + $ownerKey, + (int) $validated['total_chunks'], + ); + } catch (RuntimeException $e) { + return response()->json(['success' => false, 'error' => $e->getMessage()], 400); + } + + return response()->json([ + 'success' => true, + 'upload_id' => $validated['upload_id'], + ]); + } + + private function ownerKey(int $userId): string + { + return 'user:'.$userId; + } +} diff --git a/app/Http/Controllers/Transfer/FilesController.php b/app/Http/Controllers/Transfer/FilesController.php index 230b01a..aebc755 100644 --- a/app/Http/Controllers/Transfer/FilesController.php +++ b/app/Http/Controllers/Transfer/FilesController.php @@ -31,7 +31,7 @@ class FilesController extends Controller 'files' => $files, 'storageBytes' => (int) $storageBytes, 'storageGb' => round($storageBytes / (1024 * 1024 * 1024), 2), - 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.15), + 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30), ]); } } diff --git a/app/Http/Controllers/Transfer/OverviewController.php b/app/Http/Controllers/Transfer/OverviewController.php index 47b436b..227d14c 100644 --- a/app/Http/Controllers/Transfer/OverviewController.php +++ b/app/Http/Controllers/Transfer/OverviewController.php @@ -61,7 +61,7 @@ class OverviewController extends Controller } $storageGb = round($storageBytes / (1024 * 1024 * 1024), 2); - $monthlyEstimateGhs = round($storageGb * (float) config('transfer.price_per_gb_month', 0.15), 2); + $monthlyEstimateGhs = round($storageGb * (float) config('transfer.price_per_gb_month', 0.30), 2); return view('transfer.dashboard', [ 'activeCount' => $activeCount, diff --git a/app/Http/Controllers/Transfer/TransferController.php b/app/Http/Controllers/Transfer/TransferController.php index 25889a1..2a7292b 100644 --- a/app/Http/Controllers/Transfer/TransferController.php +++ b/app/Http/Controllers/Transfer/TransferController.php @@ -7,6 +7,7 @@ use App\Models\Transfer; use App\Services\Qr\QrImageGeneratorService; use App\Services\Qr\QrPdfExporter; use App\Services\Transfer\TransferService; +use App\Services\Upload\ChunkedUploadService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -19,6 +20,7 @@ class TransferController extends Controller { public function __construct( private TransferService $transfers, + private ChunkedUploadService $chunkedUploads, private QrImageGeneratorService $imageGenerator, private QrPdfExporter $pdfExporter, ) {} @@ -42,7 +44,7 @@ class TransferController extends Controller return view('transfer.transfers.create', [ 'defaultRetentionDays' => (int) config('transfer.default_retention_days', 30), 'maxFiles' => (int) config('transfer.max_files_per_transfer', 20), - 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.15), + 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30), ]); } @@ -50,19 +52,47 @@ class TransferController extends Controller { $account = ladill_account(); + $maxBytes = (int) config('transfer.max_file_bytes', 0); + $fileRules = ['nullable', 'file']; + if ($maxBytes > 0) { + $fileRules[] = 'max:'.(int) ceil($maxBytes / 1024); + } + $data = $request->validate([ 'title' => 'required|string|max:120', 'message' => 'nullable|string|max:2000', 'password' => 'nullable|string|min:4|max:64', 'retention_days' => 'nullable|integer|min:1|max:365', - 'files' => 'required|array|min:1', - 'files.*' => 'required|file|max:'.((int) config('transfer.max_file_bytes', 524288000) / 1024), + 'files' => 'nullable|array', + 'files.*' => $fileRules, + 'upload_ids' => 'nullable|array', + 'upload_ids.*' => 'required|string|uuid', ]); + $ownerKey = 'user:'.$account->id; + $files = array_values($request->file('files', []) ?? []); + $uploadIds = array_values($data['upload_ids'] ?? []); + + if ($files === [] && $uploadIds === []) { + return back()->withInput()->with('error', 'Add at least one file to share.'); + } + + foreach ($uploadIds as $uploadId) { + try { + $files[] = $this->chunkedUploads->toUploadedFile($uploadId, $ownerKey); + } catch (RuntimeException $e) { + return back()->withInput()->with('error', $e->getMessage()); + } + } + try { - $transfer = $this->transfers->create($account, $data, $request->file('files', [])); + $transfer = $this->transfers->create($account, $data, $files); } catch (RuntimeException $e) { return back()->withInput()->with('error', $e->getMessage()); + } finally { + foreach ($uploadIds as $uploadId) { + $this->chunkedUploads->cleanup($uploadId); + } } return redirect() diff --git a/app/Models/Transfer.php b/app/Models/Transfer.php index de3dbaa..8615faa 100644 --- a/app/Models/Transfer.php +++ b/app/Models/Transfer.php @@ -79,6 +79,6 @@ class Transfer extends Model public function monthlyCostGhs(): float { - return round($this->storageGb() * (float) config('transfer.price_per_gb_month', 0.15), 2); + return round($this->storageGb() * (float) config('transfer.price_per_gb_month', 0.30), 2); } } diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php index 049eb4f..00c93b4 100644 --- a/app/Services/Afia/AfiaService.php +++ b/app/Services/Afia/AfiaService.php @@ -96,7 +96,7 @@ class AfiaService private function transferSystemPrompt(string $ctx): string { - $pricePerGb = number_format((float) config('transfer.price_per_gb_month', 0.15), 2); + $pricePerGb = number_format((float) config('transfer.price_per_gb_month', 0.30), 2); return <<getSize() > $maxBytes) { + if ($maxBytes > 0 && $file->getSize() > $maxBytes) { throw new RuntimeException($file->getClientOriginalName().' exceeds the maximum file size.'); } diff --git a/app/Services/Upload/ChunkedUploadService.php b/app/Services/Upload/ChunkedUploadService.php new file mode 100644 index 0000000..c68bdf4 --- /dev/null +++ b/app/Services/Upload/ChunkedUploadService.php @@ -0,0 +1,192 @@ + $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} */ + 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 */ + 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 $metadata */ + private function writeMetadata(string $uploadId, array $metadata): void + { + file_put_contents($this->metadataPath($uploadId), json_encode($metadata)); + } +} diff --git a/config/transfer.php b/config/transfer.php index fa92206..9e836a5 100644 --- a/config/transfer.php +++ b/config/transfer.php @@ -9,10 +9,16 @@ return [ ]), // GHS per GB per month of retention (see qr-suite-decomposition.md §7.3). - 'price_per_gb_month' => (float) env('TRANSFER_PRICE_PER_GB_MONTH', 0.15), + 'price_per_gb_month' => (float) env('TRANSFER_PRICE_PER_GB_MONTH', 0.30), - // Max single-file upload size (bytes). Default 500 MB. - 'max_file_bytes' => (int) env('TRANSFER_MAX_FILE_BYTES', 524288000), + // Max single-file upload size (bytes). 0 = no limit (chunked uploads handle large files). + 'max_file_bytes' => (int) env('TRANSFER_MAX_FILE_BYTES', 0), + + // Bit-sized upload sessions (init → chunk → finalize), same pattern as hosting file manager. + 'chunk_upload' => [ + 'path' => env('TRANSFER_CHUNK_UPLOAD_PATH', storage_path('app/chunked_uploads')), + 'chunk_size_bytes' => (int) env('TRANSFER_CHUNK_SIZE_BYTES', 5 * 1024 * 1024), + ], // Max files per transfer. 'max_files_per_transfer' => (int) env('TRANSFER_MAX_FILES', 20), diff --git a/deploy/shared.env.example b/deploy/shared.env.example index 8f1102d..a7b2a78 100644 --- a/deploy/shared.env.example +++ b/deploy/shared.env.example @@ -37,8 +37,8 @@ BILLING_API_KEY_TRANSFER= IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_KEY_TRANSFER= -TRANSFER_PRICE_PER_GB_MONTH=0.15 -TRANSFER_MAX_FILE_BYTES=524288000 +TRANSFER_PRICE_PER_GB_MONTH=0.30 +TRANSFER_MAX_FILE_BYTES=0 TRANSFER_MAX_FILES=20 TRANSFER_DEFAULT_RETENTION_DAYS=30 TRANSFER_MAIL_RETENTION_DAYS=30 diff --git a/resources/js/app.js b/resources/js/app.js index f9f7f99..7f3fe90 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,6 +1,7 @@ import './bootstrap'; import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals'; +import { shouldUseChunkedUpload, uploadFileChunked } from './chunked-upload'; registerLadillModalHelpers(); @@ -300,6 +301,90 @@ Alpine.data('topbarSearch', (config = {}) => ({ }, })); +Alpine.data('transferCreateForm', (config = {}) => ({ + selectedFiles: [], + uploading: false, + submitting: false, + uploadProgress: 0, + uploadLabel: '', + prepared: false, + + onFilesChange(event) { + this.selectedFiles = Array.from(event.target.files || []); + this.prepared = false; + }, + + async submit(event) { + if (this.prepared) { + return; + } + + event.preventDefault(); + const form = event.target; + const fileInput = form.querySelector('#files'); + const files = this.selectedFiles.length + ? this.selectedFiles + : Array.from(fileInput?.files || []); + + if (!files.length) { + window.alert('Add at least one file to share.'); + return; + } + + this.uploading = true; + this.uploadProgress = 0; + const smallFiles = []; + + try { + form.querySelectorAll('input[name="upload_ids[]"]').forEach((node) => node.remove()); + + for (let index = 0; index < files.length; index++) { + const file = files[index]; + this.uploadLabel = `Uploading ${file.name} (${index + 1}/${files.length})…`; + + if (shouldUseChunkedUpload(file.size)) { + const result = await uploadFileChunked(file, { + initUrl: config.initUrl, + chunkUrl: config.chunkUrl, + finalizeUrl: config.finalizeUrl, + csrfToken: config.csrfToken, + onProgress: (percent) => { + this.uploadProgress = percent; + }, + }); + + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.name = 'upload_ids[]'; + hidden.value = result.uploadId; + form.appendChild(hidden); + } else { + smallFiles.push(file); + } + } + + if (fileInput && smallFiles.length) { + const dataTransfer = new DataTransfer(); + smallFiles.forEach((file) => dataTransfer.items.add(file)); + fileInput.files = dataTransfer.files; + } else if (fileInput) { + fileInput.removeAttribute('required'); + fileInput.value = ''; + } + + this.uploading = false; + this.submitting = true; + this.prepared = true; + form.submit(); + } catch (error) { + this.uploading = false; + this.submitting = false; + this.prepared = false; + window.alert(error?.message || 'Upload failed. Please try again.'); + } + }, +})); + window.Alpine = Alpine; registerLadillConfirmStore(Alpine); diff --git a/resources/js/chunked-upload.js b/resources/js/chunked-upload.js new file mode 100644 index 0000000..85d1091 --- /dev/null +++ b/resources/js/chunked-upload.js @@ -0,0 +1,121 @@ +const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; +const CHUNKED_THRESHOLD = 5 * 1024 * 1024; + +/** + * @param {File} file + * @param {{ + * chunkSize?: number, + * initUrl: string, + * chunkUrl: string, + * finalizeUrl: string, + * csrfToken?: string, + * extraInit?: Record, + * extraChunk?: Record, + * extraFinalize?: Record, + * onProgress?: (percent: number) => void, + * }} options + */ +export async function uploadFileChunked(file, options) { + const chunkSize = options.chunkSize || DEFAULT_CHUNK_SIZE; + const totalChunks = Math.ceil(file.size / chunkSize); + const maxRetries = 3; + const headers = { Accept: 'application/json' }; + if (options.csrfToken) { + headers['X-CSRF-TOKEN'] = options.csrfToken; + } + + const initRes = await fetch(options.initUrl, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename: file.name, + filesize: file.size, + ...(options.extraInit || {}), + }), + }); + const initData = await initRes.json(); + if (!initData.success) { + throw new Error(initData.error || initData.message || 'Failed to initialize upload.'); + } + + const uploadId = initData.upload_id; + + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const chunk = file.slice(start, end); + const expectedSize = end - start; + + let success = false; + let lastError = null; + + for (let attempt = 0; attempt < maxRetries && !success; attempt++) { + try { + const formData = new FormData(); + formData.append('upload_id', uploadId); + formData.append('chunk_index', String(i)); + formData.append('chunk_size', String(expectedSize)); + formData.append('chunk', chunk, `chunk_${i}`); + if (options.extraChunk) { + Object.entries(options.extraChunk).forEach(([key, value]) => { + formData.append(key, String(value)); + }); + } + + const chunkRes = await fetch(options.chunkUrl, { + method: 'POST', + headers, + body: formData, + }); + + if (!chunkRes.ok) { + throw new Error(`HTTP ${chunkRes.status}`); + } + + const chunkData = await chunkRes.json(); + if (!chunkData.success) { + throw new Error(chunkData.error || chunkData.message || 'Chunk upload failed.'); + } + + success = true; + } catch (error) { + lastError = error; + if (attempt < maxRetries - 1) { + await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1))); + } + } + } + + if (!success) { + throw new Error( + `Failed to upload chunk ${i + 1}/${totalChunks}: ${lastError?.message || 'unknown error'}`, + ); + } + + if (typeof options.onProgress === 'function') { + options.onProgress(Math.round(((i + 1) / totalChunks) * 100)); + } + } + + const finalRes = await fetch(options.finalizeUrl, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + upload_id: uploadId, + total_chunks: totalChunks, + ...(options.extraFinalize || {}), + }), + }); + const finalData = await finalRes.json(); + if (!finalData.success && !finalData.data?.public_url) { + throw new Error(finalData.error || finalData.message || 'Failed to finalize upload.'); + } + + return { uploadId, response: finalData }; +} + +export function shouldUseChunkedUpload(fileSize, threshold = CHUNKED_THRESHOLD) { + return fileSize > threshold; +} + +export { CHUNKED_THRESHOLD, DEFAULT_CHUNK_SIZE }; diff --git a/resources/views/transfer/dashboard.blade.php b/resources/views/transfer/dashboard.blade.php index d92fcb6..27490b7 100644 --- a/resources/views/transfer/dashboard.blade.php +++ b/resources/views/transfer/dashboard.blade.php @@ -58,7 +58,7 @@

Wallet balance

{{ $fmt($balanceMinor) }}

-

Storage is billed at GHS {{ number_format((float) config('transfer.price_per_gb_month', 0.15), 2) }} per GB per month from your Ladill wallet.

+

Storage is billed at GHS {{ number_format((float) config('transfer.price_per_gb_month', 0.30), 2) }} per GB per month from your Ladill wallet.

New transfer diff --git a/resources/views/transfer/transfers/create.blade.php b/resources/views/transfer/transfers/create.blade.php index 6a91c6f..85e314a 100644 --- a/resources/views/transfer/transfers/create.blade.php +++ b/resources/views/transfer/transfers/create.blade.php @@ -11,7 +11,15 @@
{{ session('error') }}
@endif -
+ @csrf
diff --git a/routes/api.php b/routes/api.php index 008f774..fa466ae 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ prefix('v1')->group(function () { Route::post('/transfers', [TransferController::class, 'store']); + Route::post('/transfers/upload/init', [ChunkedUploadController::class, 'init']); + Route::post('/transfers/upload/chunk', [ChunkedUploadController::class, 'chunk']); + Route::post('/transfers/upload/finalize', [ChunkedUploadController::class, 'finalize']); }); Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->prefix('v1')->group(function () { diff --git a/routes/web.php b/routes/web.php index 952fd4f..1ab123c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Qr\AccountController; use App\Http\Controllers\Qr\AfiaController; use App\Http\Controllers\Qr\TeamController; use App\Http\Controllers\Transfer\AnalyticsController; +use App\Http\Controllers\Transfer\ChunkedUploadController; use App\Http\Controllers\Transfer\FilesController; use App\Http\Controllers\Transfer\OverviewController; use App\Http\Controllers\Transfer\TransferController; @@ -42,6 +43,9 @@ Route::middleware(['auth'])->group(function () { Route::get('/transfers', [TransferController::class, 'index'])->name('transfer.transfers.index'); Route::get('/transfers/create', [TransferController::class, 'create'])->name('transfer.transfers.create'); + Route::post('/transfers/upload/init', [ChunkedUploadController::class, 'init'])->name('transfer.transfers.upload.init'); + Route::post('/transfers/upload/chunk', [ChunkedUploadController::class, 'chunk'])->name('transfer.transfers.upload.chunk'); + Route::post('/transfers/upload/finalize', [ChunkedUploadController::class, 'finalize'])->name('transfer.transfers.upload.finalize'); Route::post('/transfers', [TransferController::class, 'store'])->name('transfer.transfers.store'); Route::get('/transfers/{transfer}', [TransferController::class, 'show'])->name('transfer.transfers.show'); Route::delete('/transfers/{transfer}', [TransferController::class, 'destroy'])->name('transfer.transfers.destroy'); diff --git a/tests/Feature/TransferApiTest.php b/tests/Feature/TransferApiTest.php index 408229f..ff248cf 100644 --- a/tests/Feature/TransferApiTest.php +++ b/tests/Feature/TransferApiTest.php @@ -59,4 +59,48 @@ class TransferApiTest extends TestCase $this->post('/api/v1/transfers', ['mailbox' => 'x@y.com']) ->assertUnauthorized(); } + + public function test_webmail_service_can_finalize_chunked_upload(): void + { + Http::fake([ + rtrim((string) config('identity.api_url'), '/').'/identity/profile*' => Http::response([ + 'data' => [ + 'public_id' => (string) Str::uuid(), + 'name' => 'Mail User', + 'email' => 'chunked@acme.com', + 'picture' => null, + ], + ]), + ]); + + $init = $this->withToken('test-webmail-key')->postJson('/api/v1/transfers/upload/init', [ + 'mailbox' => 'chunked@acme.com', + 'filename' => 'archive.zip', + 'filesize' => 12, + ])->assertOk()->json(); + + $uploadId = (string) ($init['upload_id'] ?? ''); + $this->assertNotSame('', $uploadId); + + $chunk = UploadedFile::fake()->createWithContent('chunk_0', str_repeat('a', 12)); + $this->withToken('test-webmail-key')->post('/api/v1/transfers/upload/chunk', [ + 'mailbox' => 'chunked@acme.com', + 'upload_id' => $uploadId, + 'chunk_index' => 0, + 'chunk_size' => 12, + 'chunk' => $chunk, + ])->assertOk(); + + $this->withToken('test-webmail-key')->postJson('/api/v1/transfers/upload/finalize', [ + 'mailbox' => 'chunked@acme.com', + 'upload_id' => $uploadId, + 'total_chunks' => 1, + 'title' => 'Email: Big file', + ]) + ->assertCreated() + ->assertJsonPath('data.title', 'Email: Big file') + ->assertJsonStructure(['data' => ['public_url', 'files']]); + + $this->assertDatabaseCount('transfers', 1); + } }